This notebook is a basic introduction to deep learning. The goal is not to use a high-level library immediately, but to see the core pieces clearly:
- What deep learning does: learn an unknown function.
- What a neural network contains: parameters, linear transformations, activation functions, and layers.
- What training means: reduce prediction error.
- What backpropagation computes: gradients of the loss with respect to parameters.
- A small example: write Julia code from scratch to learn a nonlinear function.
1. Why should economists care about deep learning?
In many economic problems, the object we want is an unknown function:
$$ y = f(x). $$
Examples include:
- value function: $V(k, z)$
- policy function: $c(k, z)$ or $k'(k, z)$
- pricing kernel
- conditional expectation: $E[y \mid x]$
- classifier: map features into a class
Traditional methods choose a function class by hand, such as polynomials, Chebyshev polynomials, or splines. Deep learning uses a parameterized function $g_\theta(x)$ to approximate $f(x)$:
$$ f(x) \approx g_\theta(x). $$
Training a neural network means choosing $\theta$ so that $g_\theta(x)$ is close to the observed data.
2. The key idea: change representation, then approximate
Deep learning is not only curve fitting. It also searches for a useful representation of the data.
Traditional function approximation is often written as:
$$ f(x) \approx h(x). $$
A neural network is closer to:
$$ f(x) \approx g(\phi(x)). $$
Here:
- $x$ is the raw input.
- $\phi(x)$ is a representation learned by earlier layers.
- $g(\cdot)$ uses that representation to predict the output.
In simple terms, the network bends, stretches, and combines the original variables into useful intermediate features, then predicts $y$ from those features.
using Pkg
function find_project_root(start_dir=pwd())
dir = abspath(start_dir)
while true
if isfile(joinpath(dir, "Project.toml"))
return dir
end
parent = dirname(dir)
parent == dir && error("Could not find Project.toml from $(start_dir)")
dir = parent
end
end
project_root = find_project_root()
redirect_stdout(devnull) do
redirect_stderr(devnull) do
Pkg.activate(project_root)
end
end
using Random
using Statistics
using LinearAlgebra
using Plots
using Printf
default(size=(760, 430), linewidth=2, label=false)
include(joinpath(project_root, "src", "NetworkDiagram.jl"))
4. Basic neural-network architecture
A feedforward fully-connected neural network is built from layers:
- input layer: the raw features $x = (x_1, x_2, \ldots, x_N)$.
- hidden layers: intermediate transformations of the input.
- output layer: the final prediction $\hat y$.
Two common words:
- depth: the number of hidden layers.
- width: the number of neurons in a hidden layer.
Fully connected means every neuron in one layer is connected to every neuron in the next layer.
Key design choices are called hyperparameters. They are not learned directly by gradient descent; the researcher chooses them before training, then checks train/test performance.
| Choice | Symbol | Meaning |
|---|---|---|
| activation function | $\phi(\cdot)$ | nonlinear transformation inside hidden layers |
| number of neurons | $M$ | width of a hidden layer |
| number of hidden layers | $J$ or $L$ | depth of the network |
| epochs | -- | how many passes/updates we run during training |
| batch size | -- | how many observations are used in each gradient update |
| learning rate | $\eta$ | step size in gradient descent |
plot_fully_connected_network_diagram(input=5, hidden_layers=[7, 7, 7], output=3)
For a general network, define $h^{(0)}=x$. Each hidden layer does two things:
- affine transformation:
$$ a^{(\ell)} = W^{(\ell)}h^{(\ell-1)} + b^{(\ell)}, $$
- nonlinear activation:
$$ h^{(\ell)} = \phi(a^{(\ell)}). $$
After $L$ hidden layers, the output layer is:
$$ \hat y = \psi(W^{(L+1)}h^{(L)} + b^{(L+1)}). $$
Here $\phi$ is the hidden-layer activation. The output activation $\psi$ depends on the task.
Common hidden-layer activations:
| Name | Formula | Main idea |
|---|---|---|
| identity | $\phi(z)=z$ | no nonlinearity |
| sigmoid | $\phi(z)=1/(1+e^{-z})$ | maps to $(0,1)$ |
| tanh | $\phi(z)=\tanh(z)=\frac{e^z-e^{-z}}{e^z+e^{-z}}$ | maps to $(-1,1)$ |
| ReLU | $\phi(z)=\max(0,z)$ | simple and common |
| leaky ReLU | $\phi(z)=\max(z,\alpha z)$ | keeps a small negative slope |
| softplus | $\phi(z)=\log(1+e^z)$ | smooth version of ReLU |
Common output-layer choices:
| Task | Output activation | Formula | Example |
|---|---|---|---|
| regression, any real value | identity | $\psi(z)=z$ | price, value function, policy value |
| positive regression | softplus or exp | $\psi(z)=\log(1+e^z)$ or $\psi(z)=e^z$ | variance, consumption, capital |
| number in $(0,1)$ | sigmoid | $\psi(z)=1/(1+e^{-z})$ | probability, share, rate |
| multi-class classification | softmax | $\psi_j(z)=\frac{e^{z_j}}{\sum_k e^{z_k}}$ | class probabilities |
5. A small task: learn an unknown function
Suppose the true function is:
$$ f(x) = \sin(3x) + 0.3x^2. $$
For now, pretend we do not know this formula. We only observe data points $(x_i, y_i)$. We will train a neural network to learn the map $x \mapsto y$.
This is the basic supervised-learning regression problem.
f_true(x) = sin(3x) + 0.3x^2
rng = MersenneTwister(123)
n_train = 200
x_train_vec = sort(4 .* rand(rng, n_train) .- 2) # x in [-2, 2]
noise = 0.08 .* randn(rng, n_train)
y_train_vec = f_true.(x_train_vec) .+ noise
x_grid_vec = collect(range(-2.3, 2.3, length=300))
y_grid_true = f_true.(x_grid_vec)
scatter(x_train_vec, y_train_vec, markersize=4, alpha=0.75, label="training data")
plot!(x_grid_vec, y_grid_true, label="true function", color=:black)
xlabel!("x")
ylabel!("y")
title!("The function we want to learn")
function fit_linear_gd(x, y; lr=0.03, epochs=2_000)
a = 0.0
b = 0.0
losses = Float64[]
n = length(x)
for epoch in 1:epochs
yhat = a .+ b .* x
err = yhat .- y
loss = mean(err .^ 2)
da = 2 * mean(err)
db = 2 * mean(err .* x)
a -= lr * da
b -= lr * db
if epoch % 20 == 0
push!(losses, loss)
end
end
return a, b, losses
end
a_hat, b_hat, linear_losses = fit_linear_gd(x_train_vec, y_train_vec)
y_linear_grid = a_hat .+ b_hat .* x_grid_vec
plot(x_grid_vec, y_grid_true, label="true function", color=:black)
scatter!(x_train_vec, y_train_vec, markersize=4, alpha=0.55, label="training data")
plot!(x_grid_vec, y_linear_grid, label="linear model", color=:red)
xlabel!("x")
ylabel!("y")
title!("A linear model is too rigid")
plot(linear_losses, label="linear training loss", color=:red)
xlabel!("recorded step")
ylabel!("MSE")
title!("The loss falls, but the model class is limited")
The linear model loss falls, but the model can only draw a straight line. The true function bends and oscillates, so a well-trained linear model still cannot represent it.
This is the basic idea of model capacity: if the model class is too simple, it cannot express the true function.
7. A minimal neural network
Now we use a neural network with one hidden layer:
$$ z_1 = W_1x + b_1, $$
$$ h = \tanh(z_1), $$
$$ \hat y = W_2h + b_2. $$
Intuition:
- $W_1x+b_1$: create many different linear transformations of the input.
- $\tanh(\cdot)$: add nonlinear bending. Without nonlinearity, stacked linear layers are still linear.
- $W_2h+b_2$: combine intermediate features into the final prediction.
Here $h$ is a simple representation.
The diagram below shows the same architecture. The notebook later uses more hidden neurons for training, but the structure is the same.
plot_intro_network_diagram(hidden=6)
# Neural-network code below expects observations in columns.
X_train = reshape(x_train_vec, 1, :)
Y_train = reshape(y_train_vec, 1, :)
X_grid = reshape(x_grid_vec, 1, :)
println("Data matrices")
println("-------------")
@printf("%-10s %12s %s\n", "object", "size", "meaning")
println(repeat("-", 72))
@printf("%-10s %12s %s\n", "X_train", string(size(X_train)), "training inputs: 1 row, observations in columns")
@printf("%-10s %12s %s\n", "Y_train", string(size(Y_train)), "training targets")
@printf("%-10s %12s %s\n", "X_grid", string(size(X_grid)), "grid used for plotting fitted curves")
nothing
function init_network(n_hidden; seed=1)
rng = MersenneTwister(seed)
return (
W1 = 0.7 .* randn(rng, n_hidden, 1),
b1 = zeros(n_hidden, 1),
W2 = 0.7 .* randn(rng, 1, n_hidden),
b2 = zeros(1, 1)
)
end
function forward(p, X)
Z1 = p.W1 * X .+ p.b1
H = tanh.(Z1)
Yhat = p.W2 * H .+ p.b2
return Yhat, H, Z1
end
mse(Yhat, Y) = mean((Yhat .- Y) .^ 2)
p = init_network(12, seed=11)
Yhat0, H0, Z10 = forward(p, X_train)
initial_mse = mse(Yhat0, Y_train)
println("Initial network check")
println("---------------------")
@printf("%-36s %10.4f\n", "MSE before training", initial_mse)
nothing
8. Backpropagation: chain rule for gradients
The training objective is still mean squared error:
$$ L(\theta)=\frac{1}{N}\sum_{i=1}^N(\hat y_i-y_i)^2. $$
We need the effect of each parameter on the loss:
$$ \frac{\partial L}{\partial W_1},\quad \frac{\partial L}{\partial b_1},\quad \frac{\partial L}{\partial W_2},\quad \frac{\partial L}{\partial b_2}. $$
Backpropagation is just the chain rule. Since:
$$ x \rightarrow z_1 \rightarrow h \rightarrow \hat y \rightarrow L, $$
we start from $L$ and move backward layer by layer.
function gradients(p, X, Y)
n = size(X, 2)
Yhat, H, Z1 = forward(p, X)
# dL/dYhat for mean squared error
dYhat = (2 / n) .* (Yhat .- Y)
# Yhat = W2 * H + b2
dW2 = dYhat * H'
db2 = sum(dYhat, dims=2)
# H = tanh(Z1), so dH/dZ1 = 1 - tanh(Z1)^2 = 1 - H^2
dH = p.W2' * dYhat
dZ1 = dH .* (1 .- H .^ 2)
# Z1 = W1 * X + b1
dW1 = dZ1 * X'
db1 = sum(dZ1, dims=2)
return (W1=dW1, b1=db1, W2=dW2, b2=db2)
end
function update!(p, g, lr)
p.W1 .-= lr .* g.W1
p.b1 .-= lr .* g.b1
p.W2 .-= lr .* g.W2
p.b2 .-= lr .* g.b2
return p
end
g = gradients(p, X_train, Y_train)
println("Gradient check")
println("--------------")
@printf("%-16s %12s\n", "block", "norm")
println(repeat("-", 31))
@printf("%-16s %12.4f\n", "W1", norm(g.W1))
@printf("%-16s %12.4f\n", "W2", norm(g.W2))
nothing
function train!(p, X, Y; lr=0.04, epochs=6_000, record_every=50)
losses = Float64[]
for epoch in 1:epochs
g = gradients(p, X, Y)
update!(p, g, lr)
if epoch % record_every == 0
Yhat, _, _ = forward(p, X)
push!(losses, mse(Yhat, Y))
end
end
return losses
end
p = init_network(24, seed=7)
nn_losses = train!(p, X_train, Y_train; lr=0.035, epochs=8_000, record_every=50)
plot(nn_losses, label="neural-network training loss", color=:blue)
xlabel!("recorded step")
ylabel!("MSE")
title!("Training loss falls as parameters improve")
y_nn_grid = vec(first(forward(p, X_grid)))
plot(x_grid_vec, y_grid_true, label="true function", color=:black)
scatter!(x_train_vec, y_train_vec, markersize=4, alpha=0.55, label="training data")
plot!(x_grid_vec, y_linear_grid, label="linear model", color=:red, linestyle=:dash)
plot!(x_grid_vec, y_nn_grid, label="neural network", color=:blue)
xlabel!("x")
ylabel!("y")
title!("The neural network learns the nonlinear shape")
This figure shows the key ability of a neural network: it is not limited to straight lines. It can approximate complex functions by combining many nonlinear hidden units.
The intuition behind universal approximation is simple: with enough hidden units and a suitable activation function, even a basic feedforward network can approximate a wide class of functions.
11. Model capacity: what if we add more hidden neurons?
More hidden neurons make the model more flexible. Flexibility is useful, but it is not automatically better:
- Too few: underfitting. The model cannot learn the true shape.
- Moderate: the model can fit while staying reasonably smooth.
- Too many: the model may chase noise. In practice, deep learning also uses regularization, dropout, early stopping, and more data.
Below we compare several hidden-layer sizes.
hidden_sizes = [2, 8, 32]
fits = Dict{Int, Vector{Float64}}()
for h in hidden_sizes
p_h = init_network(h, seed=10 + h)
train!(p_h, X_train, Y_train; lr=0.035, epochs=6_000, record_every=200)
fits[h] = vec(first(forward(p_h, X_grid)))
end
plot(x_grid_vec, y_grid_true, label="true function", color=:black)
scatter!(x_train_vec, y_train_vec, markersize=3, alpha=0.35, label="training data")
for h in hidden_sizes
plot!(x_grid_vec, fits[h], label="$h hidden neurons")
end
xlabel!("x")
ylabel!("y")
title!("Capacity changes what the network can express")
12. Why do activation functions matter?
Without activation functions, stacked linear transformations are still just one linear transformation. For example:
$$ W_2(W_1x+b_1)+b_2 = (W_2W_1)x + (W_2b_1+b_2). $$
Nonlinear activations are what let neural networks express complex functions.
Common activation functions:
- sigmoid: $\sigma(z)=1/(1+e^{-z})$
- tanh: $\tanh(z)$
- ReLU: $\max(0,z)$
- softplus: $\log(1+e^z)$
Activations determine how the network bends and transforms the input space.
sigmoid(z) = 1 / (1 + exp(-z))
relu(z) = max(0, z)
softplus(z) = log1p(exp(z))
z = collect(range(-4, 4, length=300))
plot(z, sigmoid.(z), label="sigmoid")
plot!(z, tanh.(z), label="tanh")
plot!(z, relu.(z), label="ReLU")
plot!(z, softplus.(z), label="softplus")
xlabel!("z")
ylabel!("activation")
title!("Common activation functions")
13. Training error is not enough: test on new points
During training, the network only sees the training points. A lower training loss means the network fits those points better, but it does not prove that the network has learned the underlying function.
The usual check is to create a test set:
- training set: points used to update the parameters.
- test set: new points not used during training.
There are two related ideas:
- In-domain test set: new points drawn from the same region as the training data. Here this means new points in $[-2,2]$.
- Out-of-domain extrapolation test: new points outside the training region. Here this means points in $[3,5]$.
The first check asks whether the model generalizes to new observations from the same environment. The second check is harder: it asks whether the model can extrapolate beyond what it saw during training.
In this toy example, we know the true data-generating function, so we can create both kinds of test points ourselves. In real data work, we usually hold out part of the observed data before training. If we also care about extrapolation, we need a separate out-of-domain check.
test_rng = MersenneTwister(456)
n_test = 200
n_oos_test = 200
# In-domain test points: new observations, but still inside the training range [-2, 2].
x_test_vec = sort(4 .* rand(test_rng, n_test) .- 2)
test_noise = 0.08 .* randn(test_rng, n_test)
y_test_vec = f_true.(x_test_vec) .+ test_noise
X_test = reshape(x_test_vec, 1, :)
# Out-of-domain test points: new observations outside the training range.
x_oos_vec = sort(2 .* rand(test_rng, n_oos_test) .+ 3) # x in [3, 5]
oos_noise = 0.08 .* randn(test_rng, n_oos_test)
y_oos_vec = f_true.(x_oos_vec) .+ oos_noise
X_oos = reshape(x_oos_vec, 1, :)
# A wider grid lets us visualize extrapolation beyond the training interval.
x_wide_grid_vec = collect(range(-2.3, 5.3, length=450))
y_wide_true = f_true.(x_wide_grid_vec)
X_wide_grid = reshape(x_wide_grid_vec, 1, :)
y_nn_wide = vec(first(forward(p, X_wide_grid)))
y_linear_wide = a_hat .+ b_hat .* x_wide_grid_vec
y_test_nn = vec(first(forward(p, X_test)))
y_oos_nn = vec(first(forward(p, X_oos)))
y_test_linear = a_hat .+ b_hat .* x_test_vec
y_oos_linear = a_hat .+ b_hat .* x_oos_vec
p_in_domain = plot(x_grid_vec, y_grid_true, label="true function", color=:black)
plot!(p_in_domain, x_grid_vec, y_linear_grid, label="linear regression", color=:red, linestyle=:dash)
plot!(p_in_domain, x_grid_vec, y_nn_grid, label="neural network", color=:blue)
scatter!(p_in_domain, x_train_vec, y_train_vec, markersize=3, alpha=0.30, label="training points", color=:gray55)
scatter!(p_in_domain, x_test_vec, y_test_vec, markersize=4, alpha=0.80, label="in-domain test", color=:orange)
xlabel!(p_in_domain, "x")
ylabel!(p_in_domain, "y")
title!(p_in_domain, "New points inside [-2, 2]")
p_oos = plot(x_wide_grid_vec, y_wide_true, label="true function", color=:black)
plot!(p_oos, x_wide_grid_vec, y_linear_wide, label="linear regression", color=:red, linestyle=:dash)
plot!(p_oos, x_wide_grid_vec, y_nn_wide, label="neural network", color=:blue)
scatter!(p_oos, x_train_vec, y_train_vec, markersize=3, alpha=0.25, label="training points", color=:gray55)
scatter!(p_oos, x_oos_vec, y_oos_vec, markersize=4, alpha=0.80, label="out-of-domain test", color=:purple)
vline!(p_oos, [2.0], label="training boundary", color=:gray35, linestyle=:dot)
xlabel!(p_oos, "x")
ylabel!(p_oos, "y")
title!(p_oos, "Out-of-domain test on [3, 5]")
plot(p_in_domain, p_oos, layout=(1, 2), size=(980, 380))
Now compare MSE on three samples:
- training points: points used to estimate the parameters.
- in-domain test points: new points from the same range as the training data.
- out-of-domain test points: new points from $[3,5]$, outside the training range.
A useful rule of thumb:
- low train MSE and low in-domain test MSE: good interpolation/generalization inside the training region.
- low train MSE but high in-domain test MSE: likely overfitting.
- low in-domain test MSE but high out-of-domain test MSE: the model learned the local shape but does not extrapolate well.
mse_vector(yhat, y) = mean((yhat .- y) .^ 2)
y_train_nn = vec(first(forward(p, X_train)))
y_train_linear = a_hat .+ b_hat .* x_train_vec
metric_rows = [
(model="linear regression", sample="train [-2, 2]", mse=mse_vector(y_train_linear, y_train_vec)),
(model="linear regression", sample="in-domain test [-2, 2]", mse=mse_vector(y_test_linear, y_test_vec)),
(model="linear regression", sample="out-of-domain test [3, 5]", mse=mse_vector(y_oos_linear, y_oos_vec)),
(model="neural network", sample="train [-2, 2]", mse=mse_vector(y_train_nn, y_train_vec)),
(model="neural network", sample="in-domain test [-2, 2]", mse=mse_vector(y_test_nn, y_test_vec)),
(model="neural network", sample="out-of-domain test [3, 5]", mse=mse_vector(y_oos_nn, y_oos_vec)),
]
println("Model comparison")
println("----------------")
@printf("%-20s | %-28s | %10s\n", "model", "sample", "MSE")
println(repeat("-", 66))
for row in metric_rows
@printf("%-20s | %-28s | %10.4f\n", row.model, row.sample, row.mse)
end
nothing
The out-of-domain result should be interpreted carefully. It is not just a normal test set; it is an extrapolation test. The model was trained only on $[-2,2]$, so it has little information about the shape of the true function on $[3,5]$.
This is why train/test evaluation must match the question we care about. If we only need interpolation, an in-domain test set is the right first check. If we need reliable predictions outside the training region, we must test that directly, and many models that look good in-domain can fail there.
14. Manifold hypothesis: Learning High Dimensional Functions
The manifold hypothesis says that high-dimensional data often have low-dimensional structure. The object may be represented in a large space, but the economically relevant observations may move near a much smaller set.
Here the input is a distribution. After discretization on 60 asset-grid points, a distribution is a vector:
$$ \mu = (\mu_1, \mu_2, \ldots, \mu_{60}) \in \mathbb{R}^{60}. $$
But not every vector in $\mathbb{R}^{60}$ is a meaningful distribution. A valid distribution must be nonnegative and sum to one. In many economic models, the distribution also moves because of a few underlying forces, such as average assets, dispersion, tail mass, or mass near a borrowing constraint.
This is exactly where deep learning can be useful. A neural network can use the full histogram and learn its own representation:
$$ \mu \rightarrow \phi(\mu) \rightarrow \hat y. $$
In a heterogeneous-agent model, a policy object may depend on an individual state, an aggregate shock, and the full cross-sectional distribution:
$$ y = g(k,z,\Phi). $$
The distribution state $\Phi$ is infinite-dimensional. A practical approximation is to replace $\Phi$ with a finite histogram $\mu$ and learn:
$$ g(k,z,\Phi) \approx g_\theta(k,z,\mu). $$
The toy example below removes $k$ and $z$ so that we can focus only on how to learn a function of a distribution:
$$ y = g(\mu). $$
Toy data-generating process
The original HA problem has an infinite-dimensional distribution state. In real applications, training data would come from solving or simulating the economic model. Here we do not want to solve a full HA model, so we create synthetic supervised-learning data:
$$ (\mu_i,y_i), \qquad i=1,\ldots,n. $$
The purpose of the toy DGP is not realism. It gives us a controlled example where we know the true target function and can compare train/test MSE.
The asset grid is:
$$ a_j \in [0.001,0.999], \qquad j=1,\ldots,60. $$
Each distribution is generated from only three latent variables:
$$ z=(z_1,z_2,z_3) \sim N(0,I_3). $$
Define three smooth shape functions on the asset grid:
$$ q_{1j}=\sin(2\pi a_j), $$
$$ q_{2j}=\exp[-80(a_j-0.25)^2]-\exp[-80(a_j-0.75)^2], $$
$$ q_{3j}=\cos(5\pi a_j). $$
These are just templates. The latent variables choose how strongly each template affects the histogram. For each asset bin, define the score:
$$ r_j(z)=1.2z_1q_{1j}+z_2q_{2j}+0.8z_3q_{3j}. $$
Then a softmax turns the scores into a valid distribution:
$$ \mu_j=\frac{\exp(r_j(z))}{\sum_{l=1}^{60}\exp(r_l(z))}. $$
So the data are generated by the map:
$$ z \in \mathbb{R}^3 \rightarrow \mu \in \mathbb{R}^{60}. $$
This is the manifold point: the representation is 60-dimensional, but the generating structure is only 3-dimensional.
Now define three distributional shape features. First define basis functions:
$$ b_{1j}=\sin(8\pi a_j), $$
$$ b_{2j}=\exp[-120(a_j-0.72)^2]-\exp[-120(a_j-0.32)^2], $$
$$ b_{3j}=\cos(11\pi a_j). $$
Then aggregate them using the full distribution:
$$ s_1=\sum_{j=1}^{60}\mu_j b_{1j}, $$
$$ s_2=\sum_{j=1}^{60}\mu_j b_{2j}, $$
$$ s_3=\sum_{j=1}^{60}\mu_j b_{3j}. $$
The target is:
$$ y = \sin(5s_1)+s_2^2-0.8s_3+\varepsilon, $$
$$ \varepsilon \sim N(0,0.01^2). $$
So the structure is:
$$ z \rightarrow \mu \rightarrow (s_1,s_2,s_3) \rightarrow y. $$
The target still depends on distributional shape, not only on mean, variance, or skewness.
ha_asset_grid = collect(range(0.001, 0.999, length=60))
ha_shape1 = sin.(2 * pi .* ha_asset_grid)
ha_shape2 = exp.(-80 .* (ha_asset_grid .- 0.25).^2) .- exp.(-80 .* (ha_asset_grid .- 0.75).^2)
ha_shape3 = cos.(5 * pi .* ha_asset_grid)
function ha_make_distribution(z)
score = 1.2 .* z[1] .* ha_shape1 .+
z[2] .* ha_shape2 .+
0.8 .* z[3] .* ha_shape3
weights = exp.(score .- maximum(score))
return weights ./ sum(weights)
end
ha_basis1 = sin.(8 * pi .* ha_asset_grid)
ha_basis2 = exp.(-120 .* (ha_asset_grid .- 0.72).^2) .- exp.(-120 .* (ha_asset_grid .- 0.32).^2)
ha_basis3 = cos.(11 * pi .* ha_asset_grid)
function ha_distribution_target(mu)
s1 = dot(mu, ha_basis1)
s2 = dot(mu, ha_basis2)
s3 = dot(mu, ha_basis3)
return sin(5 * s1) + s2^2 - 0.8 * s3
end
function ha_sample_distribution_data(n; seed=1)
rng = MersenneTwister(seed)
X = zeros(length(ha_asset_grid), n)
Y = zeros(1, n)
Z = zeros(3, n)
for i in 1:n
z = randn(rng, 3)
mu = ha_make_distribution(z)
X[:, i] = length(ha_asset_grid) .* mu
Y[1, i] = ha_distribution_target(mu) + 0.01 * randn(rng)
Z[:, i] = z
end
return X, Y, Z
end
X_ha_train, Y_ha_train, Z_ha_train = ha_sample_distribution_data(1000, seed=10)
X_ha_test, Y_ha_test, Z_ha_test = ha_sample_distribution_data(300, seed=11)
plot(title="Some 60-dimensional distribution inputs", xlabel="asset grid", ylabel="scaled density")
for i in 1:6
plot!(ha_asset_grid, X_ha_train[:, i], label="dist $i", alpha=0.8)
end
plot!()
Baseline: moment approximation
A traditional approximation compresses the full histogram into a small number of hand-picked moments.
Compute mean, variance, and third central moment:
$$ m=\sum_j a_j\mu_j, $$
$$ v=\sum_j (a_j-m)^2\mu_j, $$
$$ s=\sum_j (a_j-m)^3\mu_j. $$
Use the feature vector:
$$ f(\mu)=\begin{bmatrix}1 & m & v & s & m^2 & v^2 & s^2 & mv & ms & vs\end{bmatrix}'. $$
The prediction is linear in these features:
$$ \hat y = \beta f(\mu). $$
The ridge estimator solves:
$$ \min_{\beta} \sum_{i=1}^n \left( y_i-\beta f(\mu_i) \right)^2 + \lambda \|\beta\|^2. $$
The first term is the usual squared prediction error. The second term,
$$ \lambda \|\beta\|^2, $$
penalizes large coefficients. This makes the fitted coefficients less sensitive to noisy or highly correlated features.
With feature matrix $F$ and target row vector $Y$, the closed-form ridge estimate is:
$$ \hat\beta = YF'(FF' + \lambda I)^{-1}. $$
Interpretation: the moment method maps $\mu \in \mathbb{R}^{60}$ into $f(\mu) \in \mathbb{R}^{10}$. This is simple and interpretable, but it may lose shape information that matters for the target.
function ha_moment_features(X)
n = size(X, 2)
F = zeros(10, n)
for i in 1:n
mu = X[:, i] ./ sum(X[:, i])
m = dot(ha_asset_grid, mu)
v = dot((ha_asset_grid .- m).^2, mu)
s = dot((ha_asset_grid .- m).^3, mu)
F[:, i] = [1.0, m, v, s, m^2, v^2, s^2, m * v, m * s, v * s]
end
return F
end
function ha_fit_ridge(F, Y; lambda=1e-6)
return Y * F' * inv(F * F' + lambda * I(size(F, 1)))
end
F_ha_train = ha_moment_features(X_ha_train)
F_ha_test = ha_moment_features(X_ha_test)
ha_beta_moments = ha_fit_ridge(F_ha_train, Y_ha_train)
Y_moment_train = ha_beta_moments * F_ha_train
Y_moment_test = ha_beta_moments * F_ha_test
Neural-network approximation
Now train a neural network on the full histogram. In the code, the input is scaled as:
$$ x = 60\mu \in \mathbb{R}^{60}. $$
The factor 60 is only a numerical scaling. It does not change the information in $\mu$.
Use a one-hidden-layer network:
$$ h = \tanh(W_1x+b_1), $$
$$ \hat y = W_2h+b_2. $$
The dimensions are:
$$ W_1 \in \mathbb{R}^{64\times 60}, \qquad b_1 \in \mathbb{R}^{64}, $$
$$ W_2 \in \mathbb{R}^{1\times 64}, \qquad b_2 \in \mathbb{R}. $$
Training solves the mean-squared-error problem:
$$ \min_\theta \frac{1}{n}\sum_{i=1}^n(\hat y_i-y_i)^2, $$
where:
$$ \theta=(W_1,b_1,W_2,b_2). $$
The hidden layer learns a representation of the distribution:
$$ \phi(\mu)=\tanh(W_1\mu+b_1). $$
So the neural-network approximation is:
$$ \mu \rightarrow \phi(\mu) \rightarrow \hat y. $$
The difference is:
- moment approximation: $\mu \rightarrow$ hand-picked moments $\rightarrow \hat y$.
- neural network: $\mu \rightarrow$ learned features from the full histogram $\rightarrow \hat y$.
Architecture used below
The next diagram shows the neural network used for this distribution-input example. The input layer is the full scaled histogram $x=60\mu\in\mathbb{R}^{60}$. The hidden layer applies the activation function $\tanh(\cdot)$, and the output layer uses the identity activation because this is a regression problem.
plot_ha_nn_architecture_diagram()
plot_ha_manifold_flow_diagram()
function ha_init_network(input_dim, hidden; seed=1)
rng = MersenneTwister(seed)
return (
W1 = 0.2 .* randn(rng, hidden, input_dim),
b1 = zeros(hidden, 1),
W2 = 0.2 .* randn(rng, 1, hidden),
b2 = zeros(1, 1),
)
end
function ha_forward(p, X)
Z1 = p.W1 * X .+ p.b1
H = tanh.(Z1)
Yhat = p.W2 * H .+ p.b2
return Yhat, H
end
ha_mse(Yhat, Y) = mean((Yhat .- Y).^2)
function ha_gradients(p, X, Y)
n = size(X, 2)
Yhat, H = ha_forward(p, X)
dYhat = (2 / n) .* (Yhat .- Y)
dW2 = dYhat * H'
db2 = sum(dYhat, dims=2)
dH = p.W2' * dYhat
dZ1 = dH .* (1 .- H.^2)
dW1 = dZ1 * X'
db1 = sum(dZ1, dims=2)
return (W1=dW1, b1=db1, W2=dW2, b2=db2)
end
function ha_update!(p, g, lr)
p.W1 .-= lr .* g.W1
p.b1 .-= lr .* g.b1
p.W2 .-= lr .* g.W2
p.b2 .-= lr .* g.b2
return p
end
function ha_train!(p, X_train, Y_train, X_test, Y_test; lr=0.003, epochs=5_000, record_every=100)
recorded_epochs = Int[]
train_losses = Float64[]
test_losses = Float64[]
for epoch in 1:epochs
g = ha_gradients(p, X_train, Y_train)
ha_update!(p, g, lr)
if epoch % record_every == 0
Yhat_train, _ = ha_forward(p, X_train)
Yhat_test, _ = ha_forward(p, X_test)
push!(recorded_epochs, epoch)
push!(train_losses, ha_mse(Yhat_train, Y_train))
push!(test_losses, ha_mse(Yhat_test, Y_test))
end
end
return recorded_epochs, train_losses, test_losses
end
p_ha = ha_init_network(size(X_ha_train, 1), 64, seed=12)
ha_epochs, ha_train_losses, ha_test_losses = ha_train!(
p_ha, X_ha_train, Y_ha_train, X_ha_test, Y_ha_test;
lr=0.003, epochs=5_000, record_every=100,
)
Y_nn_train, _ = ha_forward(p_ha, X_ha_train)
Y_nn_test, _ = ha_forward(p_ha, X_ha_test)
plot(ha_epochs, ha_train_losses, label="train MSE", color=:blue)
plot!(ha_epochs, ha_test_losses, label="test MSE", color=:orange, linestyle=:dash)
xlabel!("epoch")
ylabel!("MSE")
title!("Training and test MSE during NN training")
ha_metric_rows = [
(method="moment approximation", sample="train", mse=ha_mse(Y_moment_train, Y_ha_train)),
(method="moment approximation", sample="test", mse=ha_mse(Y_moment_test, Y_ha_test)),
(method="neural network", sample="train", mse=ha_mse(Y_nn_train, Y_ha_train)),
(method="neural network", sample="test", mse=ha_mse(Y_nn_test, Y_ha_test)),
]
println("HA distribution-input comparison")
println("--------------------------------")
@printf("%-24s | %-8s | %10s\n", "method", "sample", "MSE")
println(repeat("-", 50))
for row in ha_metric_rows
@printf("%-24s | %-8s | %10.4f\n", row.method, row.sample, row.mse)
end
nothing
lo = minimum(vec(Y_ha_test))
hi = maximum(vec(Y_ha_test))
p_moment = scatter(vec(Y_ha_test), vec(Y_moment_test),
markersize=4, alpha=0.65, label=false,
xlabel="true target", ylabel="predicted target",
title="Moment approximation")
plot!(p_moment, [lo, hi], [lo, hi], color=:black, linestyle=:dash, label=false)
p_nn = scatter(vec(Y_ha_test), vec(Y_nn_test),
markersize=4, alpha=0.65, label=false,
xlabel="true target", ylabel="predicted target",
title="Neural network")
plot!(p_nn, [lo, hi], [lo, hi], color=:black, linestyle=:dash, label=false)
plot(p_moment, p_nn, layout=(1, 2), size=(920, 360))
Diagnostic: did the hidden layer learn the latent structure?
In this toy example, we know the latent variables $z_i$ that generated each histogram $\mu_i$. After training the neural network, we can check whether the hidden layer contains this low-dimensional information.
Let
$$ h_i = \tanh(W_1x_i+b_1) $$
be the hidden-layer representation. A simple linear probe asks whether $z_i$ can be recovered from $h_i$:
$$ z_i \approx Ah_i+c. $$
If the out-of-sample $R^2$ is high, the hidden layer has learned information about the low-dimensional state behind the 60-dimensional histogram. This does not mean one neuron equals one latent variable; the representation can be rotated or transformed.
_, H_ha_train = ha_forward(p_ha, X_ha_train)
_, H_ha_test = ha_forward(p_ha, X_ha_test)
function ha_fit_linear_probe(H, Z; lambda=1e-6)
G = vcat(ones(1, size(H, 2)), H)
return Z * G' * inv(G * G' + lambda * I(size(G, 1)))
end
function ha_probe_predict(A, H)
G = vcat(ones(1, size(H, 2)), H)
return A * G
end
function ha_r2_by_row(Zhat, Z)
[1 - sum((Zhat[j, :] .- Z[j, :]).^2) / sum((Z[j, :] .- mean(Z[j, :])).^2)
for j in 1:size(Z, 1)]
end
A_probe = ha_fit_linear_probe(H_ha_train, Z_ha_train)
Zhat_train = ha_probe_predict(A_probe, H_ha_train)
Zhat_test = ha_probe_predict(A_probe, H_ha_test)
r2_train = ha_r2_by_row(Zhat_train, Z_ha_train)
r2_test = ha_r2_by_row(Zhat_test, Z_ha_test)
println("Linear probe: recover latent z from hidden layer h")
println("--------------------------------------------------")
@printf("%-8s | %10s | %10s\n", "latent", "train R2", "test R2")
println(repeat("-", 36))
for j in 1:3
@printf("%-8s | %10.4f | %10.4f\n", "z$j", r2_train[j], r2_test[j])
end
nothing
Interpretation
The comparison is the same train/test logic as before.
- If the moment approximation has high test MSE, its chosen moments are not rich enough for this target.
- If the neural network has low test MSE, it is using the full histogram to learn shape features that generalize to new distributions.
The point is not that moments are always bad. The point is narrower:
- if the policy or target depends on distributional shape, a small number of hand-picked moments may miss relevant information;
- a neural network can use the full histogram and learn shape features automatically;
- this gives a finite-dimensional approximation to an object that originally depends on an infinite-dimensional distribution state.
For HA models, the approximation logic is:
$$ g(k,z,\Phi) \approx g_\theta(k,z,\mu). $$