Yuxuan / Economics Notebook library

DeepLearningMacro

Intro to Deep Learning

Neural-network foundations and automatic differentiation for computational macroeconomics.

Julia 1.12.5 notebook · Static rendering · Code was not re-executed

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:

  1. What deep learning does: learn an unknown function.
  2. What a neural network contains: parameters, linear transformations, activation functions, and layers.
  3. What training means: reduce prediction error.
  4. What backpropagation computes: gradients of the loss with respect to parameters.
  5. 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.

3. Julia setup

We use basic Julia plus Random, Statistics, LinearAlgebra, and Plots. This keeps the internal steps visible.

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"))
plot_ha_nn_architecture_diagram

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)
No description has been provided for this image

For a general network, define $h^{(0)}=x$. Each hidden layer does two things:

  1. affine transformation:

$$ a^{(\ell)} = W^{(\ell)}h^{(\ell-1)} + b^{(\ell)}, $$

  1. 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")
No description has been provided for this image

6. Start with the simplest model: linear regression

The linear model is:

$$ \hat y = a + bx. $$

Here $a$ and $b$ are parameters. Training means choosing $a,b$ to minimize mean squared error:

$$ L(a,b)=\frac{1}{N}\sum_{i=1}^N (a + bx_i - y_i)^2. $$

We first write gradient descent by hand.

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")
No description has been provided for this image
plot(linear_losses, label="linear training loss", color=:red)
xlabel!("recorded step")
ylabel!("MSE")
title!("The loss falls, but the model class is limited")
No description has been provided for this image

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)
No description has been provided for this image
# 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
Data matrices
-------------
object             size    meaning
------------------------------------------------------------------------
X_train        (1, 200)    training inputs: 1 row, observations in columns
Y_train        (1, 200)    training targets
X_grid         (1, 300)    grid used for plotting fitted curves
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
Initial network check
---------------------
MSE before training                     12.1460

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
Gradient check
--------------
block                    norm
-------------------------------
W1                    12.1880
W2                    10.3118

9. Train the network

Gradient descent updates parameters by:

$$ \theta \leftarrow \theta - \eta \nabla_\theta L(\theta), $$

where $\eta$ is the learning rate. If it is too small, training is slow. If it is too large, training can become unstable.

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")
No description has been provided for this image
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")
No description has been provided for this image

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.

10. What did the hidden layer learn?

Each hidden neuron creates an intermediate feature:

$$ h_j(x)=\tanh(w_jx+b_j). $$

The final prediction is a weighted sum of these features:

$$ \hat y = b_2 + \sum_j W_{2j}h_j(x). $$

This is the simplest version of representation learning.

_, H_grid, _ = forward(p, X_grid)

plot(title="Some learned hidden features h_j(x)", xlabel="x", ylabel="activation")
for j in 1:6
    plot!(x_grid_vec, vec(H_grid[j, :]), label="h_$j")
end
plot!()
No description has been provided for this image

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")
No description has been provided for this image

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")
No description has been provided for this image

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))
No description has been provided for this image

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
Model comparison
----------------
model                | sample                       |        MSE
------------------------------------------------------------------
linear regression    | train [-2, 2]                |     0.6159
linear regression    | in-domain test [-2, 2]       |     0.6287
linear regression    | out-of-domain test [3, 5]    |    29.2387
neural network       | train [-2, 2]                |     0.0287
neural network       | in-domain test [-2, 2]       |     0.0244
neural network       | out-of-domain test [3, 5]    |     9.9249

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!()
No description has been provided for this image

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
1×300 Matrix{Float64}:
 -0.110116  0.0645524  -0.481413  -0.836727  …  0.0143395  -0.108237  -0.2773

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()
No description has been provided for this image

Structure of the example

The diagram below summarizes the two approximation routes. The moment method compresses the distribution into chosen statistics. The neural network uses the full histogram and learns features from it.

plot_ha_manifold_flow_diagram()
No description has been provided for this image
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")
No description has been provided for this image

Results

We report train and test MSE for both methods. The test set contains new distributions generated from the same data-generating process but not used in training.

The comparison asks whether each approximation can recover the mapping from distributional shape to target value.

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
HA distribution-input comparison
--------------------------------
method                   | sample   |        MSE
--------------------------------------------------
moment approximation     | train    |     0.0482
moment approximation     | test     |     0.0506
neural network           | train    |     0.0017
neural network           | test     |     0.0023
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))
No description has been provided for this image

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
Linear probe: recover latent z from hidden layer h
--------------------------------------------------
latent   |   train R2 |    test R2
------------------------------------
z1       |     0.9900 |     0.9783
z2       |     0.9917 |     0.9845
z3       |     0.9988 |     0.9973

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). $$