# 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 2: Deep Learning Fundamentals
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 how backpropagation computes gradients through a network
- Implement a training loop with mini-batch stochastic gradient descent
- Diagnose and fix overfitting using regularisation techniques
- Choose appropriate learning rates, optimisers, and architectures
1. Gradient descent from first principles
In PNM you minimised functions using Newton’s method and BFGS. Neural-network training uses a simpler family of methods — gradient descent — because computing the full Hessian is too expensive when you have thousands of parameters.
The update rule
\[\theta_{t+1} = \theta_t - \eta \, \nabla_\theta \mathcal{L}(\theta_t)\]
where \(\eta\) is the learning rate — the single most important hyperparameter in deep learning.
# Gradient descent on a simple 2D function (Rosenbrock)
def rosenbrock(x, y):
return (1 - x)**2 + 100 * (y - x**2)**2
# Gradient descent path
x, y = torch.tensor(-1.5, requires_grad=True), torch.tensor(1.5, requires_grad=True)
lr = 0.001
path = [(x.item(), y.item())]
for step in range(5000):
f = rosenbrock(x, y)
f.backward()
with torch.no_grad():
x -= lr * x.grad
y -= lr * y.grad
x.grad.zero_()
y.grad.zero_()
if step % 500 == 0:
path.append((x.item(), y.item()))
path.append((x.item(), y.item()))
path = np.array(path)
# Plot
X, Y = np.meshgrid(np.linspace(-2, 2, 100), np.linspace(-1, 3, 100))
Z = (1 - X)**2 + 100 * (Y - X**2)**2
fig, ax = plt.subplots(figsize=(8, 6))
ax.contour(X, Y, np.log10(Z + 1), levels=30, cmap='viridis', alpha=0.6)
ax.plot(path[:, 0], path[:, 1], 'o-', color='#7a2318', markersize=4, linewidth=1.5, label='GD path')
ax.plot(1, 1, '*', color='gold', markersize=15, label='Minimum (1, 1)')
ax.set_xlabel('x'); ax.set_ylabel('y')
ax.set_title('Gradient Descent on the Rosenbrock Function', fontsize=12)
ax.legend()
plt.tight_layout()
plt.show()2. Backpropagation — the chain rule, automated
Backpropagation is just the chain rule applied systematically through the network’s computation graph. PyTorch does this for you with .backward(), but understanding the principle is essential.
For a network \(f = W_2 \sigma(W_1 x + b_1) + b_2\):
\[\frac{\partial \mathcal{L}}{\partial W_1} = \frac{\partial \mathcal{L}}{\partial f} \cdot \frac{\partial f}{\partial \sigma} \cdot \frac{\partial \sigma}{\partial (W_1 x + b_1)} \cdot \frac{\partial (W_1 x + b_1)}{\partial W_1}\]
Each factor is a matrix — the “Jacobian” of that step. Backpropagation computes these products right-to-left (reverse mode), which is efficient when there are many parameters but few outputs.
3. Optimisers: SGD, Adam, and L-BFGS
| Optimiser | Key idea | When to use |
|---|---|---|
| SGD | Vanilla gradient descent | Baseline; rarely used alone |
| SGD + Momentum | Accumulate past gradients | Smoother convergence |
| Adam | Adaptive per-parameter learning rates | Default for most tasks |
| L-BFGS | Quasi-Newton (uses curvature) | Fine-tuning after Adam; small problems |
For economic models, a common recipe is: start with Adam for 80% of training, then switch to L-BFGS for final refinement.
# Comparing optimisers on a toy function-approximation task
target_fn = lambda x: torch.sin(3 * x) * torch.exp(-0.5 * x**2)
x_train = torch.linspace(-3, 3, 200).reshape(-1, 1)
y_train = target_fn(x_train)
optimiser_configs = {
'SGD (lr=0.01)': lambda p: torch.optim.SGD(p, lr=0.01),
'SGD+Momentum': lambda p: torch.optim.SGD(p, lr=0.01, momentum=0.9),
'Adam (lr=0.01)': lambda p: torch.optim.Adam(p, lr=0.01),
}
fig, ax = plt.subplots(figsize=(9, 5))
for name, make_opt in optimiser_configs.items():
model = nn.Sequential(nn.Linear(1, 64), nn.Tanh(), nn.Linear(64, 1))
# Same initialisation
torch.manual_seed(0)
for m in model:
if isinstance(m, nn.Linear):
nn.init.xavier_normal_(m.weight)
nn.init.zeros_(m.bias)
opt = make_opt(model.parameters())
losses = []
for ep in range(2000):
pred = model(x_train)
loss = nn.MSELoss()(pred, y_train)
opt.zero_grad(); loss.backward(); opt.step()
losses.append(loss.item())
ax.semilogy(losses, linewidth=1.5, label=name)
ax.set_xlabel('Epoch'); ax.set_ylabel('MSE Loss')
ax.set_title('Optimiser Comparison', fontsize=12)
ax.legend(fontsize=10); ax.grid(True, alpha=0.3)
plt.tight_layout(); plt.show()4. Overfitting and regularisation
Overfitting means the network memorises training data instead of learning the underlying function. Defences:
- Early stopping: monitor validation loss; stop when it starts rising
- Weight decay (L2 regularisation): penalise large weights: \(\mathcal{L}_{\text{total}} = \mathcal{L} + \lambda \|\theta\|^2\)
- Dropout: randomly zero out neurons during training (forces redundancy)
For DEQNs, overfitting is less of a concern because we sample fresh data each epoch. But it matters when we fit surrogates to simulation output (Week 9).
5. Practical tips for economic applications
- Normalise inputs. Capital \(k \in [0.1, 100]\) → rescale to \([0, 1]\) or standardise.
- Start with Adam, lr=1e-3. Reduce if training is unstable; increase if too slow.
- Use learning-rate schedulers.
ReduceLROnPlateauis simple and effective. - Monitor the loss carefully. Plot it. If it plateaus, change the learning rate or architecture.
- Tanh for smooth economics. ReLU for fast prototyping. Softplus for positivity constraints.
Exercises
Exercise 1: Implement weight decay. Train the same network with weight_decay=0 and weight_decay=1e-4 in Adam. Compare training and validation loss curves.
Exercise 2: Learning rate sensitivity. Train with lr ∈ {0.1, 0.01, 0.001, 0.0001}. Plot all loss curves on one graph. What happens when lr is too high? Too low?
Next week: Automatic Differentiation for Economics — forward vs reverse mode, and why it matters for equilibrium conditions.