# Setup
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
torch.manual_seed(42)
np.random.seed(42)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"PyTorch {torch.__version__} on {device}")Week 4: Deep Equilibrium Networks I — The Idea
Deep Learning for Macroeconomics — Honours, The University of Edinburgh
Instructor: Juan Zurita · juan.zurita@ed.ac.uk
Learning objectives
By the end of this notebook you will be able to:
- Explain the DEQN principle: solve economic models by minimising equilibrium-condition residuals
- Contrast DEQNs with VFI and projection methods from PNM
- Implement a DEQN to solve the deterministic Brock–Mirman growth model
- Verify the neural-network solution against the closed-form answer
- Diagnose convergence using Euler-equation residuals
Why this matters
In Week 1, we trained a neural network to match a known policy function. That’s supervised learning — we needed the answer to generate training data.
Deep Equilibrium Networks (Azinovic, Gaegauf & Scheidegger, 2022) flip this around: we don’t need the answer. Instead, we train the network to satisfy the model’s equilibrium conditions. The loss function comes from economics, not from data.
This is the central idea of the course. Everything that follows — stochastic models, constraints, PINNs, heterogeneous agents — is a variation on this theme.
1. From VFI to DEQNs — the conceptual shift
What you did in PNM (Week 8): Value-Function Iteration
- Define a grid over the state space \(\{k_1, k_2, \ldots, k_N\}\)
- Guess \(V^0(k)\)
- For each grid point, solve: \[V^{n+1}(k_i) = \max_{c} \Big\{ u(c) + \beta V^n\big(k_i^\alpha - c\big) \Big\}\]
- Repeat until \(\|V^{n+1} - V^n\| < \varepsilon\)
Limitations: requires a grid (curse of dimensionality), iterates over all grid points (slow in high-D), and interpolates between points (error accumulates).
What we do now: Deep Equilibrium Networks
- Parameterise the policy function as a neural network: \(c(k; \theta)\)
- Write down the Euler equation (the equilibrium condition the policy must satisfy): \[u'(c_t) = \beta \, u'(c_{t+1}) \, f'(k_{t+1})\]
- Define the loss as the squared Euler-equation residual: \[\mathcal{L}(\theta) = \mathbb{E}_k \Big[ \big| u'(c(k;\theta)) - \beta \, u'\big(c(k';\theta)\big) \, f'(k') \big|^2 \Big]\] where \(k' = f(k) - c(k;\theta)\)
- Minimise \(\mathcal{L}(\theta)\) using gradient descent
No grid. No iteration over the value function. No interpolation.
The neural network finds the policy function that makes the Euler equation hold as closely as possible everywhere.
Key insight: The loss function encodes economics, not data. We don’t need training labels — the model’s own equilibrium conditions tell the network what to learn.
2. The Brock–Mirman growth model
We start with the simplest possible dynamic model, because it has a closed-form solution — letting us verify that the DEQN works.
Setup
A representative agent solves: \[\max_{\{c_t\}_{t=0}^\infty} \sum_{t=0}^{\infty} \beta^t \ln c_t\]
subject to: \[k_{t+1} = k_t^\alpha - c_t, \quad k_0 \text{ given}\]
where \(c_t\) is consumption, \(k_t\) is capital, \(\alpha \in (0,1)\) is the capital share, and \(\beta \in (0,1)\) is the discount factor. Full depreciation is assumed (\(\delta = 1\)).
Euler equation
The first-order condition gives: \[\frac{1}{c_t} = \beta \, \frac{\alpha k_{t+1}^{\alpha - 1}}{c_{t+1}}\]
Rearranging: \[\frac{1}{c_t} - \beta \, \frac{\alpha k_{t+1}^{\alpha - 1}}{c_{t+1}} = 0\]
This is the equilibrium condition that the DEQN will learn to satisfy.
Analytical solution
With log utility and Cobb–Douglas production, the optimal policy is: \[c^*(k) = (1 - \alpha\beta) \, k^\alpha\]
We’ll use this to check our neural-network solution.
3. Building the DEQN — step by step
Step 1: Define the policy network
The neural network takes capital \(k\) as input and outputs consumption \(c\). We need \(c > 0\) and \(c < k^\alpha\) (can’t consume more than output), so we use a softplus output activation to ensure positivity and scale appropriately.
# Model parameters
ALPHA = 0.33 # capital share
BETA = 0.95 # discount factor
def production(k):
"""f(k) = k^α"""
return k ** ALPHA
def marginal_product(k):
"""f'(k) = α k^(α-1)"""
return ALPHA * k ** (ALPHA - 1)
def true_policy(k):
"""Analytical solution: c*(k) = (1 - αβ) k^α"""
return (1 - ALPHA * BETA) * k ** ALPHA
print(f"Parameters: α = {ALPHA}, β = {BETA}")
print(f"Steady-state capital: k* = (αβ)^(1/(1-α)) = {(ALPHA * BETA) ** (1 / (1 - ALPHA)):.4f}")
print(f"Steady-state consumption: c* = (1-αβ)(αβ)^(α/(1-α)) = {true_policy((ALPHA * BETA) ** (1 / (1 - ALPHA))):.4f}")class PolicyNetwork(nn.Module):
"""
Neural network that maps capital k → consumption c.
Architecture: k → [Linear → Tanh] × 2 → Linear → Softplus
The Softplus output ensures c > 0 always.
We also scale the output so it stays below k^α (total output).
"""
def __init__(self, hidden_dim=64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(1, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, 1),
)
# Initialise weights small so initial policy is moderate
for layer in self.net:
if isinstance(layer, nn.Linear):
nn.init.xavier_normal_(layer.weight, gain=0.5)
nn.init.zeros_(layer.bias)
def forward(self, k):
"""
Returns consumption c ∈ (0, k^α).
We use sigmoid to map to (0, 1), then multiply by output k^α.
This guarantees 0 < c < k^α (budget feasibility).
"""
raw = self.net(k)
# Map to (0, 1) via sigmoid, then scale to (0, k^α)
share = torch.sigmoid(raw) # consumption share of output
output = k ** ALPHA # total output
c = share * output * 0.99 # small buffer to avoid c = k^α exactly
return c
# Create the policy network
policy_net = PolicyNetwork(hidden_dim=64).to(device)
n_params = sum(p.numel() for p in policy_net.parameters())
print(f"Policy network: 1 → 64 → 64 → 1")
print(f"Total parameters: {n_params}")
print(f"Output: c ∈ (0, k^α) — always feasible")Step 2: Define the Euler-equation loss
This is the heart of the DEQN. The loss measures how badly the Euler equation is violated:
\[\mathcal{L}(\theta) = \frac{1}{N} \sum_{i=1}^{N} \left( \frac{1}{c(k_i; \theta)} - \beta \, \frac{\alpha \, [k_i']^{\alpha-1}}{c(k_i'; \theta)} \right)^2\]
where \(k_i' = k_i^\alpha - c(k_i; \theta)\) is next-period capital.
Notice: no training labels appear. The loss comes entirely from the model’s own equations.
def euler_residual(policy_net, k):
"""
Compute the Euler-equation residual for a batch of capital values.
Euler equation: 1/c_t = β · α · k_{t+1}^{α-1} / c_{t+1}
Residual: 1/c_t - β · α · k_{t+1}^{α-1} / c_{t+1}
Parameters:
policy_net: neural network mapping k → c
k: tensor of current capital values, shape (N, 1)
Returns:
residual: tensor of Euler residuals, shape (N, 1)
"""
# Current period
c_t = policy_net(k) # consumption today
k_next = k ** ALPHA - c_t # capital tomorrow (budget constraint)
# Next period — feed k' THROUGH THE SAME NETWORK
c_next = policy_net(k_next) # consumption tomorrow
# Euler equation: marginal utility today = β × MPK × marginal utility tomorrow
# With log utility: u'(c) = 1/c
lhs = 1.0 / c_t # u'(c_t)
rhs = BETA * ALPHA * k_next ** (ALPHA - 1) / c_next # β f'(k') u'(c')
residual = lhs - rhs
return residual
def euler_loss(policy_net, k):
"""Mean squared Euler-equation residual — the DEQN loss function."""
residual = euler_residual(policy_net, k)
return torch.mean(residual ** 2)
# Test with random capital values
k_test_batch = torch.FloatTensor(10, 1).uniform_(0.5, 3.0).to(device)
test_residual = euler_residual(policy_net, k_test_batch)
print("Initial Euler residuals (should be large — network is untrained):")
print(test_residual.detach().cpu().numpy().flatten().round(3))
print(f"\nInitial loss: {euler_loss(policy_net, k_test_batch).item():.4f}")Step 3: Train the DEQN
The training loop is almost identical to Week 1, except: - The loss is the Euler-equation residual (no training labels) - We sample fresh capital values each epoch (no fixed dataset) - We train until the residual is small enough (convergence)
Sampling fresh \(k\) each epoch is important: it prevents the network from memorising specific capital values and forces it to learn the function \(c(k)\).
# Training the DEQN
# Domain for capital
K_MIN, K_MAX = 0.1, 5.0
N_SAMPLE = 512 # batch size
# Optimiser
optimizer = torch.optim.Adam(policy_net.parameters(), lr=1e-3)
# Learning rate scheduler (reduce lr when progress stalls)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, patience=500, factor=0.5, verbose=False
)
# Training loop
n_epochs = 8000
losses = []
best_loss = float('inf')
for epoch in range(n_epochs):
# Sample FRESH capital values each epoch
k_batch = torch.FloatTensor(N_SAMPLE, 1).uniform_(K_MIN, K_MAX).to(device)
# Compute Euler-equation loss
loss = euler_loss(policy_net, k_batch)
# Gradient step
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step(loss)
# Record
loss_val = loss.item()
losses.append(loss_val)
if loss_val < best_loss:
best_loss = loss_val
if (epoch + 1) % 2000 == 0:
lr = optimizer.param_groups[0]['lr']
print(f"Epoch {epoch+1:5d} | Loss: {loss_val:.2e} | LR: {lr:.1e}")
print(f"\nFinal loss (Euler residual²): {losses[-1]:.2e}")
print(f"Best loss: {best_loss:.2e}")4. Verifying the solution
This is the moment of truth. We have the analytical solution \(c^*(k) = (1 - \alpha\beta) k^\alpha\). Let’s compare it to what the DEQN found.
Three checks: 1. Visual comparison of policy functions 2. Pointwise error across the domain 3. Euler-equation residuals (the economist’s gold standard for solution accuracy)
# Evaluate the trained policy network
policy_net.eval()
k_eval = torch.linspace(K_MIN, K_MAX, 500).reshape(-1, 1).to(device)
with torch.no_grad():
c_nn = policy_net(k_eval).cpu().numpy().flatten()
k_np = k_eval.cpu().numpy().flatten()
c_true = true_policy(k_np)
# --- Figure 1: Policy function comparison ---
fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
# Panel A: Policy functions
axes[0].plot(k_np, c_true, 'k-', linewidth=2.5, label='Analytical: $(1-\\alpha\\beta)k^{\\alpha}$')
axes[0].plot(k_np, c_nn, '--', color='#7a2318', linewidth=2, label='DEQN (neural network)')
axes[0].set_xlabel('Capital $k$', fontsize=11)
axes[0].set_ylabel('Consumption $c$', fontsize=11)
axes[0].set_title('A. Policy Functions', fontsize=12)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)
# Panel B: Absolute error
error = np.abs(c_nn - c_true)
axes[1].plot(k_np, error, color='#7a2318', linewidth=1.5)
axes[1].set_xlabel('Capital $k$', fontsize=11)
axes[1].set_ylabel('$|c_{NN}(k) - c^*(k)|$', fontsize=11)
axes[1].set_title('B. Absolute Error', fontsize=12)
axes[1].ticklabel_format(axis='y', style='scientific', scilimits=(0,0))
axes[1].grid(True, alpha=0.3)
# Panel C: Training loss
axes[2].semilogy(losses, color='#2a6e3f', linewidth=0.8, alpha=0.7)
# Smooth version
window = 100
smoothed = np.convolve(losses, np.ones(window)/window, mode='valid')
axes[2].semilogy(range(window-1, len(losses)), smoothed, color='#2a6e3f', linewidth=2)
axes[2].set_xlabel('Epoch', fontsize=11)
axes[2].set_ylabel('Euler residual² (loss)', fontsize=11)
axes[2].set_title('C. Convergence', fontsize=12)
axes[2].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Summary statistics
rel_error = error / c_true
print(f"Maximum absolute error: {np.max(error):.6f}")
print(f"Mean absolute error: {np.mean(error):.6f}")
print(f"Maximum relative error: {np.max(rel_error):.4%}")
print(f"Mean relative error: {np.mean(rel_error):.4%}")Euler-equation residuals: the economist’s error metric
In computational economics, the standard way to assess solution quality is the Euler-equation error (Judd, 1992):
\[\text{EE}(k) = \log_{10}\left| 1 - \frac{\beta \, u'(c(k';\theta)) \, f'(k')}{u'(c(k;\theta))} \right|\]
Interpretation: - \(\text{EE} = -3\) means the agent makes a $1 error per $1,000 of consumption - \(\text{EE} = -6\) means $1 per $1,000,000 — excellent - \(\text{EE} < -4\) is generally considered acceptable for economic research
# Euler-equation errors (log10 scale)
policy_net.eval()
k_ee = torch.linspace(K_MIN + 0.1, K_MAX - 0.1, 400).reshape(-1, 1).to(device)
k_ee.requires_grad_(False)
with torch.no_grad():
c_t = policy_net(k_ee)
k_next = k_ee ** ALPHA - c_t
c_next = policy_net(k_next)
# Euler equation: u'(c_t) = β f'(k') u'(c_{t+1})
# With log utility: 1/c_t = β α k'^(α-1) / c_{t+1}
lhs = 1.0 / c_t
rhs = BETA * ALPHA * k_next ** (ALPHA - 1) / c_next
# EE error in log10
ee_error = torch.log10(torch.abs(1 - rhs / lhs) + 1e-16)
k_ee_np = k_ee.cpu().numpy().flatten()
ee_np = ee_error.cpu().numpy().flatten()
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(k_ee_np, ee_np, color='#7a2318', linewidth=1.5)
ax.axhline(y=-3, color='gray', linestyle='--', alpha=0.7, label='$10^{-3}$: \$1 per \$1,000')
ax.axhline(y=-6, color='gray', linestyle=':', alpha=0.7, label='$10^{-6}$: \$1 per \$1,000,000')
ax.set_xlabel('Capital $k$', fontsize=11)
ax.set_ylabel('$\log_{10}$ Euler-equation error', fontsize=11)
ax.set_title('Euler-Equation Residuals — DEQN Solution Quality', fontsize=12)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"Mean EE error (log10): {np.mean(ee_np):.2f}")
print(f"Max EE error (log10): {np.max(ee_np):.2f}")
print(f"→ The DEQN solution is {'excellent' if np.max(ee_np) < -3 else 'good' if np.max(ee_np) < -2 else 'needs more training'}")5. Under the hood — what the DEQN is actually doing
Let’s trace through one training step to demystify the process:
- Sample \(k = 2.0\)
- Forward pass: $c(2.0; ) = $ the network’s current guess for consumption
- Budget constraint: $k’ = 2.0^{0.33} - c(2.0; ) = $ next-period capital
- Forward pass again: $c(k’; ) = $ next-period consumption (same network!)
- Euler residual: \(\frac{1}{c_t} - \beta \frac{\alpha [k']^{\alpha-1}}{c_{t+1}}\)
- Loss: square of the residual, averaged over the batch
- Backpropagation: compute how each weight should change to reduce the residual
- Update: adjust weights
The crucial step is (4): we feed the network’s own output back through itself. This is what makes the solution self-consistent — the policy at time \(t\) must be consistent with the policy at time \(t+1\).
# Trace through one step for a single capital value
k_example = torch.tensor([[2.0]], device=device)
policy_net.eval()
with torch.no_grad():
c_t = policy_net(k_example)
y_t = k_example ** ALPHA
k_next = y_t - c_t
c_next = policy_net(k_next)
mpk = ALPHA * k_next ** (ALPHA - 1)
lhs = 1.0 / c_t
rhs = BETA * mpk / c_next
residual = lhs - rhs
print("=== DEQN: One Step Trace ===")
print(f"")
print(f"Current capital: k = {k_example.item():.4f}")
print(f"Output: y = k^α = {y_t.item():.4f}")
print(f"NN consumption: c = {c_t.item():.4f}")
print(f"True consumption: c* = {true_policy(k_example.item()):.4f}")
print(f"")
print(f"Next-period capital: k' = y - c = {k_next.item():.4f}")
print(f"Next-period consumption: c' = NN(k') = {c_next.item():.4f}")
print(f"Marginal product: αk'^(α-1) = {mpk.item():.4f}")
print(f"")
print(f"Euler LHS (1/c): {lhs.item():.4f}")
print(f"Euler RHS (β·MPK/c'): {rhs.item():.4f}")
print(f"Residual (should be ≈0): {residual.item():.6f}")6. DEQN vs VFI — head to head
Let’s solve the same model with VFI (as you learned in PNM) and compare:
# VFI solution (from PNM Week 8)
# Grid for VFI
N_GRID = 200
k_grid = np.linspace(K_MIN, K_MAX, N_GRID)
V = np.zeros(N_GRID) # initial guess
# VFI iteration
for iteration in range(500):
V_new = np.zeros(N_GRID)
c_vfi = np.zeros(N_GRID)
for i, k in enumerate(k_grid):
y = k ** ALPHA
# Candidate consumption values
c_candidates = np.linspace(0.01, y - 0.01, 200)
k_next_candidates = y - c_candidates
# Interpolate V at k' values
V_next = np.interp(k_next_candidates, k_grid, V)
# Bellman equation
values = np.log(c_candidates) + BETA * V_next
# Optimal consumption
best_idx = np.argmax(values)
V_new[i] = values[best_idx]
c_vfi[i] = c_candidates[best_idx]
if np.max(np.abs(V_new - V)) < 1e-8:
print(f"VFI converged in {iteration+1} iterations")
break
V = V_new.copy()
# Compare VFI, DEQN, and analytical solution
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
axes[0].plot(k_np, c_true, 'k-', linewidth=2.5, label='Analytical')
axes[0].plot(k_np, c_nn, '--', color='#7a2318', linewidth=2, label='DEQN')
axes[0].plot(k_grid, c_vfi, ':', color='#2a6e3f', linewidth=2, label=f'VFI ({N_GRID} points)')
axes[0].set_xlabel('Capital $k$', fontsize=11)
axes[0].set_ylabel('Consumption $c$', fontsize=11)
axes[0].set_title('Policy Functions: Three Methods', fontsize=12)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)
# Errors
c_vfi_interp = np.interp(k_np, k_grid, c_vfi)
err_vfi = np.abs(c_vfi_interp - c_true) / c_true * 100
err_deqn = np.abs(c_nn - c_true) / c_true * 100
axes[1].plot(k_np, err_vfi, color='#2a6e3f', linewidth=1.5, label='VFI')
axes[1].plot(k_np, err_deqn, color='#7a2318', linewidth=1.5, label='DEQN')
axes[1].set_xlabel('Capital $k$', fontsize=11)
axes[1].set_ylabel('Relative error (%)', fontsize=11)
axes[1].set_title('Relative Error Comparison', fontsize=12)
axes[1].legend(fontsize=10)
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"VFI — max relative error: {np.max(err_vfi):.4f}%")
print(f"DEQN — max relative error: {np.max(err_deqn):.4f}%")When does each method win?
| Criterion | VFI | DEQN |
|---|---|---|
| Low dimensions (1-2 states) | ✅ Fast, reliable | Works but overkill |
| High dimensions (10+ states) | ❌ Curse of dimensionality | ✅ Scales gracefully |
| Occasionally binding constraints | ✅ Easy (just check) | Requires special treatment |
| Continuous-time models | ❌ Needs discretisation | ✅ Natural with PINNs |
| Guaranteed convergence | ✅ (contraction mapping) | ❌ (gradient descent) |
| Accuracy for a given compute time | ✅ in 1-D | ✅ in high-D |
Bottom line: DEQNs are not a replacement for VFI — they are a complement. Use VFI when you can, DEQNs when you must (high dimensions, continuous time, complex constraints).
Exercise 1: Effect of network size
Train DEQNs with different architectures on the same Brock–Mirman model and compare their Euler-equation errors.
Tasks:
- Train networks with hidden layers: 1×16, 1×64, 2×32, 2×64
- Plot the Euler-equation errors for each architecture
- Which architecture gives the best accuracy for its parameter count?
Click for solution
# Exercise 1 solution
class DEQNPolicy(nn.Module):
def __init__(self, layers):
super().__init__()
modules = []
for i in range(len(layers) - 1):
modules.append(nn.Linear(layers[i], layers[i+1]))
if i < len(layers) - 2:
modules.append(nn.Tanh())
self.net = nn.Sequential(*modules)
for m in self.net:
if isinstance(m, nn.Linear):
nn.init.xavier_normal_(m.weight, gain=0.5)
nn.init.zeros_(m.bias)
def forward(self, k):
raw = self.net(k)
share = torch.sigmoid(raw)
return share * k ** ALPHA * 0.99
architectures = {
'1×16': [1, 16, 1],
'1×64': [1, 64, 1],
'2×32': [1, 32, 32, 1],
'2×64': [1, 64, 64, 1],
}
results = {}
for name, layers in architectures.items():
model = DEQNPolicy(layers).to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, patience=500, factor=0.5)
for ep in range(6000):
k_b = torch.FloatTensor(512, 1).uniform_(K_MIN, K_MAX).to(device)
loss = euler_loss(model, k_b)
opt.zero_grad(); loss.backward(); opt.step()
sched.step(loss)
# Compute EE errors
model.eval()
with torch.no_grad():
k_ee = torch.linspace(K_MIN + 0.1, K_MAX - 0.1, 300).reshape(-1, 1).to(device)
c_t = model(k_ee); k_n = k_ee**ALPHA - c_t; c_n = model(k_n)
ee = torch.log10(torch.abs(1 - (BETA * ALPHA * k_n**(ALPHA-1) / c_n) / (1/c_t)) + 1e-16)
n_p = sum(p.numel() for p in model.parameters())
mean_ee = ee.cpu().numpy().mean()
results[name] = {'params': n_p, 'ee': ee.cpu().numpy(), 'mean_ee': mean_ee}
print(f"{name:6s} | params: {n_p:5d} | mean EE: {mean_ee:.2f}")
# Plot
fig, ax = plt.subplots(figsize=(9, 4.5))
k_plot = torch.linspace(K_MIN + 0.1, K_MAX - 0.1, 300).numpy()
for name, res in results.items():
ax.plot(k_plot, res['ee'].flatten(), linewidth=1.5,
label=f"{name} ({res['params']} params, mean EE: {res['mean_ee']:.1f})")
ax.axhline(y=-3, color='gray', linestyle='--', alpha=0.5)
ax.set_xlabel('Capital $k$', fontsize=11)
ax.set_ylabel('$\log_{10}$ Euler-equation error', fontsize=11)
ax.set_title('Euler-Equation Errors by Architecture', fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()Exercise 2: Different preference parameter
Solve the Brock–Mirman model for CRRA utility \(u(c) = \frac{c^{1-\gamma}}{1-\gamma}\) with \(\gamma = 2\) instead of log utility.
Note: With CRRA utility and \(\gamma \neq 1\), the model no longer has a closed-form solution — this is exactly the kind of problem where DEQNs shine.
Tasks:
- Modify the Euler-equation residual for CRRA utility: \(u'(c) = c^{-\gamma}\)
- Train a DEQN and plot the resulting policy function
- Compare to the log-utility policy (which we know is \((1 - \alpha\beta) k^\alpha\))
Click for solution
# Exercise 2 solution
GAMMA = 2.0 # risk aversion
def euler_loss_crra(policy_net, k, gamma=GAMMA):
"""Euler-equation loss with CRRA utility: u'(c) = c^(-γ)"""
c_t = policy_net(k)
k_next = k ** ALPHA - c_t
c_next = policy_net(k_next)
lhs = c_t ** (-gamma)
rhs = BETA * ALPHA * k_next ** (ALPHA - 1) * c_next ** (-gamma)
residual = lhs - rhs
return torch.mean(residual ** 2)
# Train
model_crra = PolicyNetwork(hidden_dim=64).to(device)
opt_crra = torch.optim.Adam(model_crra.parameters(), lr=1e-3)
sched_crra = torch.optim.lr_scheduler.ReduceLROnPlateau(opt_crra, patience=500, factor=0.5)
for ep in range(8000):
k_b = torch.FloatTensor(512, 1).uniform_(K_MIN, K_MAX).to(device)
loss = euler_loss_crra(model_crra, k_b)
opt_crra.zero_grad(); loss.backward(); opt_crra.step()
sched_crra.step(loss)
if (ep + 1) % 2000 == 0:
print(f"Epoch {ep+1}: loss = {loss.item():.2e}")
# Compare CRRA and log-utility policies
model_crra.eval()
with torch.no_grad():
c_crra = model_crra(k_eval).cpu().numpy().flatten()
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(k_np, c_true, 'k-', linewidth=2, label='Log utility (analytical)')
ax.plot(k_np, c_nn, '--', color='#7a2318', linewidth=2, label='Log utility (DEQN)')
ax.plot(k_np, c_crra, '-.', color='#2a6e3f', linewidth=2, label=f'CRRA γ={GAMMA} (DEQN)')
ax.set_xlabel('Capital $k$', fontsize=11)
ax.set_ylabel('Consumption $c$', fontsize=11)
ax.set_title('Policy Functions: Log vs CRRA Utility', fontsize=12)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print("With higher risk aversion (γ=2), the agent saves more (consumes less)")
print("at any given capital level — exactly what economic intuition predicts.")
print("\n→ This solution has NO closed form. The DEQN found it directly from the Euler equation.")Key takeaways
DEQNs solve models by minimising equilibrium-condition residuals. No training labels, no grid, no value-function iteration. The loss function comes from economics.
The Euler equation becomes the loss function. \(\mathcal{L}(\theta) = \mathbb{E}[\text{Euler residual}^2]\). When \(\mathcal{L} \approx 0\), the policy function satisfies the model’s optimality conditions.
The network feeds its output back through itself. Today’s policy determines tomorrow’s state, and tomorrow’s policy comes from the same network. This self-consistency is what makes the solution an equilibrium.
Euler-equation errors are the standard way to assess solution quality. \(\log_{10}|\text{EE}| < -3\) is acceptable; \(< -6\) is excellent.
DEQNs scale to problems where grids fail. The same approach works in 1, 10, or 100 dimensions — the network doesn’t know or care about the dimensionality.
What’s next
Week 5: We add stochastic productivity shocks — the model becomes an actual RBC framework. This requires: - Gauss–Hermite quadrature for computing expectations in the Euler equation - Modified loss functions that average over tomorrow’s uncertainty - Verification against Kydland & Prescott-style simulations
Week 6: We tackle occasionally binding constraints (borrowing limits) — a feature that makes models much harder to solve with traditional methods but is natural with DEQNs.
References
- Azinovic, M., Gaegauf, L., & Scheidegger, S. (2022). “Deep Equilibrium Nets.” International Economic Review, 63(4), 1471–1525.
- Scheidegger, S. (2025). Deep Learning for Solving and Estimating Dynamic Economic Models — Lectures 3–4.
- Judd, K. (1992). “Projection Methods for Solving Aggregate Growth Models.” Journal of Economic Theory, 58(2), 410–452.
Next week: Deep Equilibrium Networks II — Stochastic Models ⚡