Course: Deep Learning for Solving and Estimating Dynamic Models in Economics and Finance
Script reference: §6.7 (Sequence-space DEQNs) -- Krusell-Smith TF2 implementation
Notebook role: core
Author: Simon Scheidegger
# Run-mode switch (smoke = CPU-bounded for CI, teaching = laptop figures, production = full reproduction).
RUN_MODE = "smoke" # one of: "smoke", "teaching", "production"
SEED = 0
Sequence-Space DEQN: Krusell-Smith Economy (TensorFlow 2)¶
This notebook solves the Krusell-Smith (1998) heterogeneous-agent economy using the sequence-space Deep Equilibrium Net approach:
| Component | Method |
|---|---|
| Policy | I-spline MPC basis monotone, concave by construction |
| Distribution | Young (2010) non-stochastic simulation on a fixed grid |
| Optimality | Fischer-Burmeister KKT residual for Euler + borrowing constraint |
| Training | Replay buffer of (Z-history, ) pairs; mini-batch SGD on FB |
Framework: TensorFlow 2 with @tf.function for the differentiable loss path.
References: Azinovic-Yang & Žemlička (2025), Deep learning in the sequence space (arXiv:2509.13623) -- the sequence-space DEQN method this notebook implements; Azinovic, Gaegauf & Scheidegger (2022), International Economic Review 63(4) -- the underlying deep-equilibrium-net framework.
import matplotlib
matplotlib.use('Agg') # non-blocking backend
import tensorflow as tf
import numpy as np
from scipy.interpolate import BSpline
import matplotlib.pyplot as plt
import time
plt.rcParams['font.size'] = 13
# ---------- tutorial / production toggle ----------
# Derived from the kit-mandated RUN_MODE in cell 1: smoke/teaching -> quick, production -> full.
TUTORIAL_MODE = "full" if RUN_MODE == "production" else "quick"
print(f"TensorFlow {tf.__version__}")
print(f"Mode: {TUTORIAL_MODE}")
print(f"GPUs: {tf.config.list_physical_devices('GPU')}")2026-04-19 18:27:19.007025: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:477] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1776616039.052916 27493 cuda_dnn.cc:8310] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
E0000 00:00:1776616039.064540 27493 cuda_blas.cc:1418] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
2026-04-19 18:27:19.100875: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
TensorFlow 2.18.0
Mode: quick
GPUs: []
2026-04-19 18:27:24.408403: E external/local_xla/xla/stream_executor/cuda/cuda_driver.cc:152] failed call to cuInit: INTERNAL: CUDA error: Failed call to cuInit: UNKNOWN ERROR (303)
Calibration¶
# ---- Model parameters ----
ALPHA = 0.36 # capital share
DELTA = 0.025 # depreciation
BETA = 0.93 # discount factor
SIGMA = 1.0 # CRRA (log utility)
# Idiosyncratic shocks
EPS_VALS = np.array([0.5, 1.5])
PI_EPS = np.array([[0.9, 0.1], # P(eps'=L | eps=L) = 0.9
[0.1, 0.9]])
# Aggregate shocks
Z_VALS = np.array([0.93, 1.07])
PI_Z = np.array([[0.7, 0.3], # persistence = 0.7
[0.3, 0.7]])
N_EPS = len(EPS_VALS)
N_Z = len(Z_VALS)
# Deterministic steady state
R_ss = 1.0 / BETA
K_ss = ((R_ss - 1.0 + DELTA) / ALPHA) ** (1.0 / (ALPHA - 1.0))
print(f"Steady-state: K_ss = {K_ss:.4f}, R_ss = {R_ss:.4f}")
# TF constants for use in the graph
EPS_VALS_tf = tf.constant(EPS_VALS, dtype=tf.float32)
PI_EPS_tf = tf.constant(PI_EPS, dtype=tf.float32)
PI_Z_tf = tf.constant(PI_Z, dtype=tf.float32)
Z_VALS_tf = tf.constant(Z_VALS, dtype=tf.float32)
def compute_prices(K, L, Z):
"""Cobb-Douglas factor prices (numpy, for simulation)."""
KL = max(K, 0.01) / max(L, 0.01)
R = 1.0 - DELTA + ALPHA * Z * KL ** (ALPHA - 1.0)
w = (1.0 - ALPHA) * Z * KL ** ALPHA
return R, w
print(f"At K_ss with Z=1: R={compute_prices(K_ss, 1.0, 1.0)[0]:.4f}, w={compute_prices(K_ss, 1.0, 1.0)[1]:.4f}")Steady-state: K_ss = 7.3689, R_ss = 1.0753
At K_ss with Z=1: R=1.0753, w=1.3135
Capital Grid¶
We use a log-spaced grid to concentrate points near where the policy is steepest (high MPC for poor households).
GRID_CONFIG = {
"quick": {"N": 60, "K_MAX": 60.0},
"full": {"N": 100, "K_MAX": 100.0},
}[TUTORIAL_MODE]
N = GRID_CONFIG["N"]
K_MAX = GRID_CONFIG["K_MAX"]
K_GRID_SHIFT = 0.5
BASIS_SHIFT = 0.5
x_grid = np.linspace(np.log(K_GRID_SHIFT), np.log(K_MAX + K_GRID_SHIFT), N + 1)
k_grid_np = np.exp(x_grid) - K_GRID_SHIFT
dk_np = np.diff(k_grid_np)
x0 = float(x_grid[0])
delta_x = float((x_grid[-1] - x_grid[0]) / N)
# TF constants
k_grid_tf = tf.constant(k_grid_np, dtype=tf.float32)
dk_tf = tf.constant(dk_np, dtype=tf.float32)
# Visualize
fig, ax = plt.subplots(figsize=(10, 1.5))
ax.scatter(k_grid_np, np.zeros(N + 1), s=6, alpha=0.7)
ax.set_xlabel("Capital")
ax.set_title(f"Log-spaced grid: {N+1} points in [0, {K_MAX}]")
ax.set_yticks([])
ax.axvline(K_ss, color='red', linestyle='--', alpha=0.5,
label=f"deterministic $K_{{ss}}={K_ss:.1f}$")
ax.legend(fontsize=9)
plt.tight_layout()
plt.close("all")
print(f"Grid: {N+1} points, k_min={k_grid_np[0]:.4f}, k_max={k_grid_np[-1]:.2f}")
print(f"Smallest interval: {dk_np[0]:.4f}, largest: {dk_np[-1]:.2f}")Grid: 61 points, k_min=0.0000, k_max=60.00
Smallest interval: 0.0416, largest: 4.65
I-Spline Basis¶
The MPC function is built from I-spline basis functions:
where each is monotonically increasing from 0 to 1. Because each basis function is non-negative and increasing, this construction guarantees the MPC is decreasing (concave consumption) by design.
N_INTERIOR = 26 if TUTORIAL_MODE == "quick" else 56
ORDER = 4 # cubic I-splines
def build_ispline_basis(k_grid, n_interior=56, order=4, BASIS_SHIFT=0.1):
"""Precompute I-spline basis at the grid points (numpy/scipy)."""
k_arr = np.asarray(k_grid)
x = np.log(BASIS_SHIFT + k_arr)
x_min, x_max = float(x[0]), float(x[-1])
# Equidistant interior knots in x-space
knots_int = np.linspace(x_min, x_max, n_interior + 2)[1:-1]
knots = np.concatenate([np.repeat(x_min, order), knots_int, np.repeat(x_max, order)])
n_basis = len(knots) - order
# Compute I-splines (= integral of B-splines)
basis = np.zeros((n_basis, len(k_arr)))
for idx in range(n_basis):
c = np.zeros(n_basis)
c[idx] = 1.0
bspl = BSpline(knots, c, order - 1, extrapolate=False)
t_span = knots[idx + order] - knots[idx]
if t_span < 1e-12:
continue
anti = bspl.antiderivative()
basis[idx] = (order / t_span) * np.nan_to_num(anti(x) - anti(x_min), 0.0)
return np.clip(basis, 0.0, 1.0)
ispline_basis_np = build_ispline_basis(k_grid_np, N_INTERIOR, ORDER, BASIS_SHIFT)
J = ispline_basis_np.shape[0]
print(f"Built {J} I-spline basis functions on {N+1} grid points")
# Store as TF constant for graph use
ispline_basis_tf = tf.constant(ispline_basis_np, dtype=tf.float32)
# Visualize
B = ispline_basis_np
print("Basis matrix shape:", B.shape)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for j in range(J):
axes[0].plot(k_grid_np, B[j], linewidth=0.8, alpha=0.5)
axes[0].set_xlabel("Capital $k$")
axes[0].set_ylabel("$I_j(k)$")
axes[0].set_title(f"I-spline basis in $k$-space ({J} functions)")
axes[0].grid(True, alpha=0.3)
x_vals = np.log(BASIS_SHIFT + k_grid_np)
for j in range(J):
axes[1].plot(x_vals, B[j], linewidth=0.8, alpha=0.5)
axes[1].set_xlabel("$x = \\log(0.5 + k)$")
axes[1].set_ylabel("$I_j(x)$")
axes[1].set_title("I-spline basis in transformed space")
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.close("all")Built 30 I-spline basis functions on 61 grid points
Basis matrix shape: (30, 61)
Neural Network Actor¶
A 2-hidden-layer MLP maps the encoded aggregate history to I-spline coefficients that define the consumption policy on the grid.
We use raw tf.Variable weights (not tf.keras.Model) to match the
functional style of the JAX reference implementation.
H = 50 # history length (last H aggregate shocks)
HIDDEN_DIM = 64 # MLP hidden layer width
INPUT_DIM = H * (N_Z + 1) # one-hot + values per lag
def init_actor(input_dim, hidden_dim, n_eps, J, seed=0):
"""Initialize a 2-hidden-layer MLP as a list of tf.Variables.
Returns [W0, b0, W1, b1, W2, b2].
"""
rng = np.random.default_rng(seed)
output_dim = n_eps * (1 + J)
W0 = tf.Variable(rng.normal(size=(input_dim, hidden_dim)).astype(np.float32)
* np.sqrt(2.0 / input_dim), name='W0')
b0 = tf.Variable(np.zeros(hidden_dim, dtype=np.float32), name='b0')
W1 = tf.Variable(rng.normal(size=(hidden_dim, hidden_dim)).astype(np.float32)
* np.sqrt(2.0 / hidden_dim), name='W1')
b1 = tf.Variable(np.zeros(hidden_dim, dtype=np.float32), name='b1')
W2 = tf.Variable(rng.normal(size=(hidden_dim, output_dim)).astype(np.float32)
* 0.01, name='W2')
b2 = tf.Variable(np.zeros(output_dim, dtype=np.float32), name='b2')
return [W0, b0, W1, b1, W2, b2]
@tf.function
def actor_c_grid(params_list, agg_input, R, w):
"""Forward pass: aggregate input -> consumption on the k-grid.
Args:
params_list: [W0, b0, W1, b1, W2, b2]
agg_input: (INPUT_DIM,) encoded Z-history
R, w: scalar factor prices (tf constants or python floats)
Returns: c_grid of shape (N_EPS, N+1)
"""
# MLP forward pass (tanh activations)
h = tf.math.tanh(tf.matmul(agg_input[tf.newaxis, :], params_list[0]) + params_list[1])
h = tf.math.tanh(tf.matmul(h, params_list[2]) + params_list[3])
raw = tf.reshape(tf.matmul(h, params_list[4]) + params_list[5], [N_EPS, 1 + J])
# Boundary MPC: sigmoid -> (0, 1)
alpha = tf.nn.sigmoid(raw[:, 0]) # (N_EPS,)
# Phantom-zero softmax: guarantees weights >= 0 and sum < 1
logits = raw[:, 1:] # (N_EPS, J)
augmented = tf.concat([tf.zeros([N_EPS, 1]), logits], axis=1)
w_tilde = tf.nn.softmax(augmented, axis=1)[:, 1:] # (N_EPS, J)
# MPC on the grid: alpha * (1 - weighted I-splines)
mpc = alpha[:, tf.newaxis] * (1.0 - tf.matmul(w_tilde, ispline_basis_tf)) # (N_EPS, N+1)
# Cumulate consumption on the grid from the MPC schedule
# Boundary: c(k_0) = MPC(k_0) * m(k_0), where m = w*eps + R*k
c0 = mpc[:, 0] * (w * EPS_VALS_tf + R * k_grid_tf[0]) # (N_EPS,)
# Increments: dc_n = MPC(k_n) * R * dk_n
dc = mpc[:, 1:] * (R * dk_tf[tf.newaxis, :]) # (N_EPS, N)
c_grid = tf.concat([c0[:, tf.newaxis],
c0[:, tf.newaxis] + tf.cumsum(dc, axis=1)], axis=1)
return c_grid
def encode_Z_history(z_hist):
"""Encode Z-history as one-hot + values (numpy, for buffer construction)."""
onehot = np.eye(N_Z, dtype=np.float32)[z_hist].reshape(-1)
values = Z_VALS[z_hist].reshape(-1).astype(np.float32)
return np.concatenate([onehot, values])
def encode_Z_history_tf(z_hist_tf):
"""Encode Z-history as one-hot + values (TF, for use inside @tf.function)."""
onehot = tf.reshape(tf.one_hot(z_hist_tf, N_Z), [-1])
values = tf.reshape(tf.gather(Z_VALS_tf, z_hist_tf), [-1])
return tf.concat([onehot, values], axis=0)
# Initialize and check
params_list = init_actor(INPUT_DIM, HIDDEN_DIM, N_EPS, J, seed=0)
n_params = sum(int(tf.size(p)) for p in params_list)
print(f"Actor parameters: {n_params}")
print(f"Input dim: {INPUT_DIM} (H={H} x {N_Z+1} features per lag)")
print(f"Output dim: {N_EPS} x (1 + {J}) = {N_EPS * (1 + J)}")
# Quick shape check with a dummy input
z_hist_dummy = np.zeros(H, dtype=np.int32)
agg_dummy = tf.constant(encode_Z_history(z_hist_dummy))
R_vis, w_vis = compute_prices(K_ss, 1.0, float(Z_VALS[0]))
c_test = actor_c_grid(params_list, agg_dummy, tf.constant(R_vis, tf.float32),
tf.constant(w_vis, tf.float32))
print(f"Output shape: {c_test.shape} (expected ({N_EPS}, {N+1}))")
# Verify shape guarantees
c_np = c_test.numpy()
m_np = w_vis * EPS_VALS[:, None] + R_vis * k_grid_np[None, :]
print(f"\nShape check for initial random policy:")
print(f" min dc across grid intervals = {np.min(np.diff(c_np, axis=1)): .3e} (should be >= 0)")
print(f" max(c - cash on hand) = {np.max(c_np - m_np): .3e} (should be <= 0)")Actor parameters: 17854
Input dim: 150 (H=50 x 3 features per lag)
Output dim: 2 x (1 + 30) = 62
Output shape: (2, 61) (expected (2, 61))
Shape check for initial random policy:
min dc across grid intervals = 2.033e-02 (should be >= 0)
max(c - cash on hand) = -3.026e-01 (should be <= 0)
Simulation: Young’s Method¶
Young’s (2010) non-stochastic simulation evolves the full distribution on the grid. Aggregates are exact weighted sums, eliminating Monte Carlo sampling noise.
All simulation functions use numpy (not TensorFlow). The simulation is
not differentiated -- in the JAX version these paths use stop_gradient.
# ---- Simulation config ----
SIM_CONFIG = {
"quick": {"T": 100, "N_AGG": 8, "BUFFER_CAP": 128},
"full": {"T": 100, "N_AGG": 8, "BUFFER_CAP": 128},
}[TUTORIAL_MODE]
T = SIM_CONFIG["T"]
N_AGG = SIM_CONFIG["N_AGG"]
BUFFER_CAP = SIM_CONFIG["BUFFER_CAP"]
def distribution_aggregates(mu):
"""Compute aggregate K and L from distribution mu(eps, k).
mu has shape (N_EPS, N+1) and sums to 1.
K = sum k * mu(eps,k), L = sum eps * mu(eps,k).
"""
K = np.sum(mu * k_grid_np[None, :])
L = np.sum(mu * EPS_VALS[:, None])
return max(K, 0.01), max(L, 0.01)
def distribution_step(mu, c_grid_t, R, w):
"""Advance the distribution one period (Young 2010, numpy).
For each (eps, k) grid point:
1. Compute next-period capital: k' = w*eps + R*k - c(eps, k)
2. Find the two neighboring grid points around k'
3. Split the mass to those neighbors (lottery)
4. Apply the idiosyncratic transition matrix PI_EPS
"""
m_grid = w * EPS_VALS[:, None] + R * k_grid_np[None, :]
k_next = np.clip(m_grid - c_grid_t, 0.0, k_grid_np[-1])
# Find grid position of k' in log-space
x_next = np.log(K_GRID_SHIFT + k_next)
j_lo = np.clip(
np.floor((x_next - x0) / delta_x).astype(np.int32), 0, N - 1)
# Lottery weights
k_lo = k_grid_np[j_lo]
k_hi = k_grid_np[j_lo + 1]
w_lo = (k_hi - k_next) / np.maximum(k_hi - k_lo, 1e-12)
w_hi = 1.0 - w_lo
# Scatter mass to neighboring grid points for each eps state
mu_after = np.zeros_like(mu)
for i in range(N_EPS):
np.add.at(mu_after[i], j_lo[i], mu[i] * w_lo[i])
np.add.at(mu_after[i], j_lo[i] + 1, mu[i] * w_hi[i])
# Apply idiosyncratic transition
mu_next = PI_EPS.T @ mu_after
return mu_next
def initial_distribution(K_target):
"""Create an initial distribution centered around K_target."""
eps_weight = np.ones(N_EPS) / N_EPS
sigma_k = K_target * 0.3
density = np.exp(-0.5 * ((k_grid_np - K_target) / sigma_k) ** 2)
density = density / np.sum(density)
mu = eps_weight[:, None] * density[None, :]
return mu / np.sum(mu)
def draw_Z_path(rng, T, z_init):
"""Draw T aggregate shocks (numpy). Returns (Z_path, z_terminal)."""
Z_path = np.zeros(T, dtype=np.int32)
z = z_init
for t in range(T):
Z_path[t] = z
z = int(rng.uniform() >= PI_Z[z, 0])
return Z_path, z
def make_agg_inputs(full_Z_seq, H, T):
"""Build encoded Z-history inputs for each simulation step (numpy)."""
inputs = np.zeros((T, H * (N_Z + 1)), dtype=np.float32)
for t in range(T):
window = full_Z_seq[t:t + H]
inputs[t] = encode_Z_history(window)
return inputs
def simulate_young(params_list, mu_init, Z_path, agg_inputs):
"""Simulate T periods using Young's non-stochastic method (numpy outer loop).
The actor_c_grid calls go through TF for the forward pass, but we
immediately convert to numpy. No gradients are tracked here.
"""
mu = mu_init.copy()
T_sim = len(Z_path)
for t in range(T_sim):
Z_idx = int(Z_path[t])
K_t, L_t = distribution_aggregates(mu)
R_t, w_t = compute_prices(K_t, L_t, Z_VALS[Z_idx])
agg_input_tf = tf.constant(agg_inputs[t])
c_grid_t = actor_c_grid(params_list, agg_input_tf,
tf.constant(R_t, tf.float32),
tf.constant(w_t, tf.float32)).numpy()
mu = distribution_step(mu, c_grid_t, R_t, w_t)
return mu
def run_simulations(rng, params_list, buffer_z, buffer_mu, idx):
"""Generate shocks, simulate distributions forward, return terminal states."""
results = []
for i in idx:
z_hist = buffer_z[i]
mu_start = buffer_mu[i]
z_init = int(z_hist[-1])
Z_path, z_terminal = draw_Z_path(rng, T, z_init)
# Build full Z-sequence for encoding (history + simulation path)
# Z_path[0] = z_init (duplicates parent's terminal), skip with [1:]
full_Z = np.concatenate([z_hist, Z_path[1:], [z_terminal]])
agg_inputs = make_agg_inputs(full_Z, H, T)
mu_final = simulate_young(params_list, mu_start, Z_path, agg_inputs)
new_z_hist = full_Z[-H:].astype(np.int32)
results.append((new_z_hist, mu_final, Z_path, z_terminal))
return results
print(f"Simulation config: T={T}, N_AGG={N_AGG}, buffer={BUFFER_CAP}")
print("Using Young (2010) non-stochastic method -- no MC noise in aggregates.")Simulation config: T=100, N_AGG=8, buffer=128
Using Young (2010) non-stochastic method -- no MC noise in aggregates.
Fischer-Burmeister Loss¶
The household’s optimality has two cases:
Interior (): Euler equation holds with equality, i.e. where
Constrained (): Euler gap
The Fischer-Burmeister function encodes both in one smooth formula:
where is the relative savings slack.
iff the KKT conditions are satisfied.
Key design: prices and aggregates enter as numpy scalars (equivalent
to stop_gradient in JAX). Only the actor_c_grid calls are traced by
tf.GradientTape, so gradients flow through the policy network only.
@tf.function
def interpolate_tf(vals_grid, eps_idx, k):
"""Piecewise-linear interpolation on the k-grid (differentiable).
Args:
vals_grid: (N_EPS, N+1) grid values (e.g. consumption)
eps_idx: (M,) integer indices into eps dimension
k: (M,) capital values to interpolate at
Returns:
(M,) interpolated values
"""
x = tf.math.log(K_GRID_SHIFT + k)
j = tf.clip_by_value(
tf.cast(tf.floor((x - x0) / delta_x), tf.int32), 0, N - 1)
k_lo = tf.gather(k_grid_tf, j)
k_hi = tf.gather(k_grid_tf, j + 1)
w_lo = (k_hi - k) / tf.maximum(k_hi - k_lo, 1e-12)
# Gather from 2D grid using (eps_idx, j) pairs
v_lo = tf.gather_nd(vals_grid, tf.stack([eps_idx, j], axis=-1))
v_hi = tf.gather_nd(vals_grid, tf.stack([eps_idx, j + 1], axis=-1))
return w_lo * v_lo + (1.0 - w_lo) * v_hi
def fb_loss_one_state_tf(params_list, z_history_np, mu_np):
"""Compute mean FB-squared on the full (eps, k) grid for one replay state.
Prices and aggregates are numpy scalars (not traced by the tape).
Only actor_c_grid calls contribute to the gradient.
Args:
params_list: [W0, b0, W1, b1, W2, b2] tf.Variables
z_history_np: (H,) numpy int32 array
mu_np: (N_EPS, N+1) numpy float64 distribution
Returns:
scalar tf.Tensor -- mean FB-squared
"""
z_t = int(z_history_np[-1])
# Current prices from distribution (exact aggregates, numpy)
K_t, L_t = distribution_aggregates(mu_np)
R_t, w_t = compute_prices(K_t, L_t, Z_VALS[z_t])
# Current policy on the full grid (TF -- traced)
agg_input = tf.constant(encode_Z_history(z_history_np))
c_grid_t = actor_c_grid(params_list, agg_input,
tf.constant(R_t, tf.float32),
tf.constant(w_t, tf.float32))
# Next-period capital for each (eps, k) on the grid
R_t_tf = tf.constant(R_t, tf.float32)
w_t_tf = tf.constant(w_t, tf.float32)
m_grid = w_t_tf * EPS_VALS_tf[:, tf.newaxis] + R_t_tf * k_grid_tf[tf.newaxis, :]
k_next_grid = tf.clip_by_value(m_grid - c_grid_t, 0.0, k_grid_tf[-1])
# Next-period aggregates from Young step (numpy, not traced)
c_grid_t_np = c_grid_t.numpy()
mu_next = distribution_step(mu_np, c_grid_t_np, R_t, w_t)
K_next, L_next = distribution_aggregates(mu_next)
# Continuation value: q = E[beta * R' * u'(c')]
q = tf.zeros_like(c_grid_t)
for z_next in range(N_Z):
z_hist_next = np.concatenate([z_history_np[1:], [z_next]]).astype(np.int32)
agg_next = tf.constant(encode_Z_history(z_hist_next))
R_next, w_next = compute_prices(K_next, L_next, Z_VALS[z_next])
c_grid_next = actor_c_grid(params_list, agg_next,
tf.constant(R_next, tf.float32),
tf.constant(w_next, tf.float32))
# For each next-eps, interpolate c' at k_next_grid
# c_next_all shape: (N_EPS, N_EPS, N+1) -- [next_eps, current_eps, k]
c_next_parts = []
for i_next in range(N_EPS):
k_flat = tf.reshape(k_next_grid, [-1]) # (N_EPS*(N+1),)
idx = tf.fill(k_flat.shape, i_next) # (N_EPS*(N+1),)
c_interp = interpolate_tf(c_grid_next, idx, k_flat)
c_next_parts.append(tf.reshape(c_interp, [N_EPS, N + 1]))
c_next_all = tf.stack(c_next_parts) # (N_EPS, N_EPS, N+1)
# u'(c') = 1/c (log utility)
mu_u = 1.0 / tf.maximum(c_next_all, 1e-10)
# E_{eps'} [u'(c')] using PI_EPS transition
exp_uc = tf.einsum('ij,jik->ik', PI_EPS_tf, mu_u) # (N_EPS, N+1)
q = q + PI_Z[z_t, z_next] * BETA * R_next * exp_uc
# Euler-implied consumption and FB residual
c_euler = 1.0 / tf.maximum(q, 1e-10)
c_scale = tf.maximum(c_grid_t, 1e-10)
g = (c_euler - c_grid_t) / c_scale # relative Euler gap
s = k_next_grid / c_scale # relative slack
fb = tf.sqrt(g**2 + s**2 + 1e-12) - g - s # Fischer-Burmeister residual
return tf.reduce_mean(fb**2)
print("FB loss defined. Prices enter as numpy scalars (stop-gradient equivalent).")
print("Only actor_c_grid calls are traced by GradientTape.")FB loss defined. Prices enter as numpy scalars (stop-gradient equivalent).
Only actor_c_grid calls are traced by GradientTape.
Training¶
Each epoch:
Sample states from the replay buffer.
Simulate forward periods with Young’s method to generate new terminal distributions.
Add terminal states to the buffer (FIFO eviction beyond capacity).
For
FB_STEPSmini-batches: compute FB loss, backprop throughactor_c_grid, update weights.
In quick mode this runs in a few minutes on CPU. Switch to full for production.
TRAIN_CONFIG = {
"quick": {"LR": 5e-5, "FB_STEPS": 10, "FB_BATCH": 16, "N_EPOCHS": 50, "LOG_EVERY": 10}, # classroom: 50 (~7 min CPU); production: 5000
"full": {"LR": 5e-5, "FB_STEPS": 10, "FB_BATCH": 16, "N_EPOCHS": 10_000, "LOG_EVERY": 200},
}[TUTORIAL_MODE]
LR = TRAIN_CONFIG["LR"]
FB_STEPS = TRAIN_CONFIG["FB_STEPS"]
FB_BATCH = TRAIN_CONFIG["FB_BATCH"]
N_EPOCHS = TRAIN_CONFIG["N_EPOCHS"]
LOG_EVERY = TRAIN_CONFIG["LOG_EVERY"]
optimizer = tf.keras.optimizers.Adam(learning_rate=LR, clipnorm=1.0)
# Seed the replay buffer
rng = np.random.default_rng(0)
buffer_z = []
buffer_mu = []
mu_init = initial_distribution(K_ss)
for _ in range(min(BUFFER_CAP, 16)):
buffer_z.append(np.zeros(H, dtype=np.int32))
buffer_mu.append(mu_init.copy())
print(f"Ready to train: {N_EPOCHS} epochs, lr={LR}, fb_steps={FB_STEPS}, fb_batch={FB_BATCH}")
print(f"Buffer seeded with {len(buffer_z)} distributions around K_ss={K_ss:.2f}")Ready to train: 50 epochs, lr=5e-05, fb_steps=10, fb_batch=16
Buffer seeded with 16 distributions around K_ss=7.37
history = []
t0 = time.time()
for epoch in range(N_EPOCHS):
# 1. Sample N_AGG states from the buffer
idx = rng.integers(0, len(buffer_z), size=N_AGG)
# 2. Simulate forward to generate new terminal distributions
sim_results = run_simulations(rng, params_list, buffer_z, buffer_mu, idx)
# 3. Add terminal states to the buffer
for new_z_hist, mu_final, _, _ in sim_results:
buffer_z.append(new_z_hist)
buffer_mu.append(mu_final)
while len(buffer_z) > BUFFER_CAP:
buffer_z.pop(0)
buffer_mu.pop(0)
# 4. Mini-batch FB gradient steps
fb2 = 0.0
for _ in range(FB_STEPS):
mb = rng.integers(0, len(buffer_z), size=min(FB_BATCH, len(buffer_z)))
# Accumulate loss over mini-batch then update
with tf.GradientTape() as tape:
batch_loss = tf.constant(0.0)
for bi in mb:
batch_loss = batch_loss + fb_loss_one_state_tf(
params_list, buffer_z[bi], buffer_mu[bi])
batch_loss = batch_loss / float(len(mb))
grads = tape.gradient(batch_loss, params_list)
optimizer.apply_gradients(zip(grads, params_list))
fb2 = float(batch_loss)
if (epoch + 1) % LOG_EVERY == 0:
fb2_val = fb2
buf_K = float(np.mean([
np.sum(m * k_grid_np[None, :]) for m in buffer_mu
]))
wall = time.time() - t0
history.append((epoch + 1, fb2_val, buf_K, wall))
print(f"Epoch {epoch + 1:5d} | FB2 = {fb2_val:.2e} | K_bar = {buf_K:.2f} | {wall:.0f}s")
wall_total = time.time() - t0
print(f"\nDone! {N_EPOCHS} epochs in {wall_total:.0f}s ({wall_total/60:.1f} min)")
print(f"Final FB2 = {fb2:.2e}")Epoch 10 | FB2 = 1.63e-02 | K_bar = 2.71 | 111s
Epoch 20 | FB2 = 1.30e-02 | K_bar = 2.46 | 207s
Epoch 30 | FB2 = 8.64e-03 | K_bar = 3.38 | 303s
Epoch 40 | FB2 = 6.21e-03 | K_bar = 4.14 | 399s
Epoch 50 | FB2 = 4.12e-03 | K_bar = 4.95 | 474s
Done! 50 epochs in 474s (7.9 min)
Final FB2 = 4.12e-03
epochs_h = [h[0] for h in history]
fb2_h = [h[1] for h in history]
fig, ax = plt.subplots(figsize=(8, 4))
ax.semilogy(epochs_h, fb2_h, linewidth=2, color='#1f77b4')
ax.axhline(1e-6, color='red', linestyle='--', alpha=0.5, label='$10^{-6}$ target')
ax.set_xlabel("Epoch", fontsize=12)
ax.set_ylabel(r"FB$^2$ (log scale)", fontsize=12)
ax.set_title("Euler Equation Accuracy During Training", fontsize=14)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.close("all")Learned Policy Functions¶
# Pick a representative buffer state for plotting
buf_K = float(np.mean([
np.sum(m * k_grid_np[None, :]) for m in buffer_mu
]))
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
colors = ["#1f77b4", "#ff7f0e"]
labels_eps = [r"$\varepsilon_L$", r"$\varepsilon_H$"]
labels_Z = [r"$Z_L$", r"$Z_H$"]
for z_idx in range(N_Z):
z_hist_plot = np.full(H, z_idx, dtype=np.int32)
agg_input = tf.constant(encode_Z_history(z_hist_plot))
R, w = compute_prices(max(buf_K, 0.01), 1.0, float(Z_VALS[z_idx]))
c_grid_learned = actor_c_grid(params_list, agg_input,
tf.constant(R, tf.float32),
tf.constant(w, tf.float32)).numpy()
ls = '-' if z_idx == 0 else '--'
for e in range(N_EPS):
# Consumption
axes[0].plot(k_grid_np, c_grid_learned[e], color=colors[e], linestyle=ls,
linewidth=2, label=f"{labels_eps[e]}, {labels_Z[z_idx]}")
# Cash-on-hand envelope
m = float(w) * float(EPS_VALS[e]) + float(R) * k_grid_np
if z_idx == 0:
axes[0].plot(k_grid_np, m, color=colors[e], linestyle=':', alpha=0.4,
label=f"$m(k, {labels_eps[e]})$" if z_idx == 0 else None)
# MPC
mpc = np.zeros_like(k_grid_np)
mpc[0] = c_grid_learned[e, 0] / max(m[0], 1e-10)
mpc[1:] = np.diff(c_grid_learned[e]) / (float(R) * dk_np)
axes[1].plot(k_grid_np, mpc, color=colors[e], linestyle=ls, linewidth=2)
axes[0].set_xlabel("Capital $k$", fontsize=12)
axes[0].set_ylabel("Consumption $c$", fontsize=12)
axes[0].set_title("Learned consumption policy", fontsize=14)
axes[0].legend(fontsize=8, loc='lower right')
axes[0].grid(True, alpha=0.3)
axes[0].set_xlim(0, min(50, K_MAX))
axes[1].set_xlabel("Capital $k$", fontsize=12)
axes[1].set_ylabel("MPC", fontsize=12)
axes[1].set_title("Marginal propensity to consume", fontsize=14)
axes[1].set_ylim(-0.05, 1.05)
axes[1].grid(True, alpha=0.3)
axes[1].set_xlim(0, min(50, K_MAX))
plt.tight_layout()
plt.close("all")
# Shape check on the last plotted policy
m_check = float(w) * EPS_VALS[:, None] + float(R) * k_grid_np[None, :]
print(f"Shape check for trained policy:")
print(f" min dc across grid intervals = {np.min(np.diff(c_grid_learned, axis=1)): .3e} (should be >= 0)")
print(f" max(c - cash on hand) = {np.max(c_grid_learned - m_check): .3e} (should be <= 0)")Shape check for trained policy:
min dc across grid intervals = 2.208e-02 (should be >= 0)
max(c - cash on hand) = -2.452e-01 (should be <= 0)
Diagnostics¶
We evaluate per-grid-point Euler gaps and FB residuals across all replay buffer states. Two diagnostic views:
Consumption and MPC vs : scatter plots at selected values (tight clouds = approximate aggregation holds).
Euler / FB error bands: median and 10th-90th percentile of across the capital grid.
def euler_diagnostics_one_state(params_list, z_history_np, mu_np):
"""Per-grid-point Euler gap and FB residual for one buffer state."""
z_t = int(z_history_np[-1])
K_t, L_t = distribution_aggregates(mu_np)
R_t, w_t = compute_prices(K_t, L_t, Z_VALS[z_t])
agg_input = tf.constant(encode_Z_history(z_history_np))
c_grid_t = actor_c_grid(params_list, agg_input,
tf.constant(R_t, tf.float32),
tf.constant(w_t, tf.float32)).numpy()
m_grid = w_t * EPS_VALS[:, None] + R_t * k_grid_np[None, :]
k_next_grid_np = np.clip(m_grid - c_grid_t, 0.0, k_grid_np[-1])
# Next-period aggregates from Young step
mu_next = distribution_step(mu_np, c_grid_t, R_t, w_t)
K_next, L_next = distribution_aggregates(mu_next)
q = np.zeros_like(c_grid_t)
for z_next in range(N_Z):
z_hist_next = np.concatenate([z_history_np[1:], [z_next]]).astype(np.int32)
agg_next = tf.constant(encode_Z_history(z_hist_next))
R_next, w_next = compute_prices(K_next, L_next, Z_VALS[z_next])
c_grid_next = actor_c_grid(params_list, agg_next,
tf.constant(R_next, tf.float32),
tf.constant(w_next, tf.float32)).numpy()
# Interpolate c' at k_next for each next-eps
c_next_all = np.zeros((N_EPS, N_EPS, N + 1))
for i_next in range(N_EPS):
k_flat = k_next_grid_np.ravel()
# Simple numpy interpolation for diagnostics
x_kn = np.log(K_GRID_SHIFT + k_flat)
j_lo = np.clip(np.floor((x_kn - x0) / delta_x).astype(np.int32), 0, N - 1)
w_lo = (k_grid_np[j_lo + 1] - k_flat) / np.maximum(
k_grid_np[j_lo + 1] - k_grid_np[j_lo], 1e-12)
c_interp = (w_lo * c_grid_next[i_next, j_lo]
+ (1.0 - w_lo) * c_grid_next[i_next, j_lo + 1])
c_next_all[i_next] = c_interp.reshape(N_EPS, N + 1)
mu_u = 1.0 / np.maximum(c_next_all, 1e-10)
exp_uc = np.einsum('ij,jik->ik', PI_EPS, mu_u)
q += PI_Z[z_t, z_next] * BETA * R_next * exp_uc
c_euler = 1.0 / np.maximum(q, 1e-10)
c_scale = np.maximum(c_grid_t, 1e-10)
g_grid = (c_euler - c_grid_t) / c_scale
s_grid = k_next_grid_np / c_scale
fb_grid = np.sqrt(g_grid**2 + s_grid**2 + 1e-12) - g_grid - s_grid
# MPC via finite-difference
mpc = np.zeros_like(c_grid_t)
mpc[:, 0] = c_grid_t[:, 0] / np.maximum(m_grid[:, 0], 1e-10)
mpc[:, 1:] = np.diff(c_grid_t, axis=1) / (R_t * dk_np[None, :])
return {
"K": float(K_t), "c_grid": c_grid_t, "mpc_grid": mpc,
"g_grid": g_grid, "fb_grid": fb_grid,
}
# Evaluate on all buffer states
print(f"Running Euler diagnostics on {len(buffer_z)} buffer states...")
diag_results = []
for i in range(len(buffer_z)):
d = euler_diagnostics_one_state(params_list, buffer_z[i], buffer_mu[i])
diag_results.append(d)
all_K = np.array([d["K"] for d in diag_results])
all_c = np.stack([d["c_grid"] for d in diag_results]) # (n_states, N_EPS, N+1)
all_mpc = np.stack([d["mpc_grid"] for d in diag_results]) # (n_states, N_EPS, N+1)
abs_g = np.abs(np.stack([d["g_grid"] for d in diag_results]))
abs_fb = np.abs(np.stack([d["fb_grid"] for d in diag_results]))
print(f"K range: [{all_K.min():.2f}, {all_K.max():.2f}]")
print(f"Mean |g| = {abs_g.mean():.2e}, Mean FB2 = {(abs_fb**2).mean():.2e}")Running Euler diagnostics on 128 buffer states...
K range: [3.91, 6.34]
Mean |g| = 4.52e-02, Mean FB2 = 3.94e-03
# --- Diagnostic Plot 1: Consumption and MPC vs K ---
target_k_vals = [0.0, 1.0, 3.0, 7.0, 15.0, 30.0, float(k_grid_np[-1])]
target_k_vals = [tk for tk in target_k_vals if tk <= float(k_grid_np[-1])]
k_indices = [int(np.argmin(np.abs(k_grid_np - tk))) for tk in target_k_vals]
colors_eps = ["#1f77b4", "#ff7f0e"]
eps_labels = [r"$\varepsilon_L$", r"$\varepsilon_H$"]
fig, axes = plt.subplots(2, len(k_indices), figsize=(3.5 * len(k_indices), 8),
sharex=True)
for col, ki in enumerate(k_indices):
for e in range(N_EPS):
axes[0, col].scatter(all_K, all_c[:, e, ki], s=3, alpha=0.3,
color=colors_eps[e],
label=eps_labels[e] if col == 0 else None)
axes[1, col].scatter(all_K, all_mpc[:, e, ki], s=3, alpha=0.3,
color=colors_eps[e])
axes[0, col].set_title(f"$k = {k_grid_np[ki]:.1f}$", fontsize=9)
axes[1, col].set_xlabel("$K$", fontsize=9)
axes[0, col].grid(True, alpha=0.3)
axes[1, col].grid(True, alpha=0.3)
axes[0, col].tick_params(labelsize=7)
axes[1, col].tick_params(labelsize=7)
axes[0, 0].set_ylabel("Consumption $c$")
axes[1, 0].set_ylabel("MPC")
axes[0, 0].legend(fontsize=7, markerscale=3)
fig.suptitle("Consumption and MPC vs aggregate capital $K$", fontsize=13, y=1.02)
plt.tight_layout()
plt.close("all")
print("Tight scatter clouds = approximate aggregation holds.")
print("Variation across K comes from the encoder mapping different Z-histories to different policy coefficients.")Tight scatter clouds = approximate aggregation holds.
Variation across K comes from the encoder mapping different Z-histories to different policy coefficients.
# --- Diagnostic Plot 2: FB error bands ---
n_states = abs_g.shape[0]
fig, axes = plt.subplots(1, N_EPS, figsize=(6.2 * N_EPS, 8.2), sharex=True)
for i in range(N_EPS):
fb_med = np.median(abs_fb[:, i, :], axis=0)
fb_p10 = np.percentile(abs_fb[:, i, :], 10.0, axis=0)
fb_p90 = np.percentile(abs_fb[:, i, :], 90.0, axis=0)
# |FB| panel
axes[i].fill_between(k_grid_np, np.maximum(fb_p10, 1e-12),
np.maximum(fb_p90, 1e-12),
color=colors_eps[i], alpha=0.18)
axes[i].plot(k_grid_np, np.maximum(fb_med, 1e-12),
color=colors_eps[i], linewidth=2.0)
axes[i].set_yscale("log")
axes[i].set_ylabel(r"$|FB(g,s)|$")
axes[i].set_xlabel("Capital $k$")
axes[i].grid(True, alpha=0.3)
axes[i].set_title(f"{eps_labels[i]}: FB error bands ({n_states} buffer states)",
fontsize=11)
plt.tight_layout()
plt.close("all")
print("The band shows the 10th-90th percentile range across buffer states.")
print("Wider bands indicate more variation in accuracy across aggregate states.")
print("High |g| near k=0 reflects the borrowing constraint; high |g| at high k")
print("is often on grid points with zero household mass.")The band shows the 10th-90th percentile range across buffer states.
Wider bands indicate more variation in accuracy across aggregate states.
High |g| near k=0 reflects the borrowing constraint; high |g| at high k
is often on grid points with zero household mass.
Summary¶
This notebook solved the Krusell-Smith (1998) heterogeneous-agent economy using the sequence-space DEQN approach in TensorFlow 2:
Represented the consumption policy with an I-spline MPC basis, so the grid policy is increasing, concave, and feasible by construction.
Evaluated equilibrium conditions with a Fischer-Burmeister residual that handles both interior and constrained households.
Trained on a replay buffer of aggregate histories and household distributions.
Diagnosed solution quality with per-grid-point Euler error bands and scatter plots showing how the policy varies with the aggregate state.
Takeaways
The I-spline MPC representation gives a concrete example of how economic shape restrictions can be built directly into a neural network.
The replay buffer is the bridge between individual policies and aggregate prices.
The scatter plot of consumption vs at fixed visualizes approximate aggregation -- tight clouds mean the policy depends mainly on prices, not on the full distribution.
The Euler/FB error band plot reveals where accuracy is best (interior ) and worst (near constraints and at high with zero household mass).
A few intentional simplifications
two-state aggregate and idiosyncratic Markov chains,
50-period Z-history (rather than an RNN encoder),
Cobb-Douglas production and log utility.
Each could be relaxed without changing the algorithm’s structure.