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:

  1. Explain the DEQN principle: solve economic models by minimising equilibrium-condition residuals
  2. Contrast DEQNs with VFI and projection methods from PNM
  3. Implement a DEQN to solve the deterministic Brock–Mirman growth model
  4. Verify the neural-network solution against the closed-form answer
  5. 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.


# 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}")

1. From VFI to DEQNs — the conceptual shift

What you did in PNM (Week 8): Value-Function Iteration

  1. Define a grid over the state space \(\{k_1, k_2, \ldots, k_N\}\)
  2. Guess \(V^0(k)\)
  3. 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\}\]
  4. 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

  1. Parameterise the policy function as a neural network: \(c(k; \theta)\)
  2. Write down the Euler equation (the equilibrium condition the policy must satisfy): \[u'(c_t) = \beta \, u'(c_{t+1}) \, f'(k_{t+1})\]
  3. 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)\)
  4. 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:

  1. Sample \(k = 2.0\)
  2. Forward pass: $c(2.0; ) = $ the network’s current guess for consumption
  3. Budget constraint: $k’ = 2.0^{0.33} - c(2.0; ) = $ next-period capital
  4. Forward pass again: $c(k’; ) = $ next-period consumption (same network!)
  5. Euler residual: \(\frac{1}{c_t} - \beta \frac{\alpha [k']^{\alpha-1}}{c_{t+1}}\)
  6. Loss: square of the residual, averaged over the batch
  7. Backpropagation: compute how each weight should change to reduce the residual
  8. 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:

  1. Train networks with hidden layers: 1×16, 1×64, 2×32, 2×64
  2. Plot the Euler-equation errors for each architecture
  3. 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:

  1. Modify the Euler-equation residual for CRRA utility: \(u'(c) = c^{-\gamma}\)
  2. Train a DEQN and plot the resulting policy function
  3. 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

  1. DEQNs solve models by minimising equilibrium-condition residuals. No training labels, no grid, no value-function iteration. The loss function comes from economics.

  2. 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.

  3. 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.

  4. Euler-equation errors are the standard way to assess solution quality. \(\log_{10}|\text{EE}| < -3\) is acceptable; \(< -6\) is excellent.

  5. 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 ⚡