Inside the Autoencoder

Autoencoders, variational autoencoders, and sparse autoencoders, built one operation at a time

Sjoerd Vink

How can a model learn a useful representation of its input without being told what that representation should contain?

This essay builds three answers, one operation at a time. Each model is a small change to the one before it.

  1. 01 Autoencoder
  2. 02 Variational
  3. 03 Sparse
encoderdecoder2.01.00.81.40.6xzxinputlatentreconstruction(2,)(1,)(2,)compare, then adjust the weights

01 Autoencoder

An autoencoder rebuilds its own input.

It has two parts. An encoder turns the input into a new set of numbers. A decoder tries to turn those numbers back into the input.

We call the input xx, the numbers in the middle zz, and the attempt to rebuild the input x^\hat{x}. The vector zz is the latent representation. It is latent because nobody observes or labels it.

x→ encoder z→ decoder x^x \xrightarrow{\ \text{encoder}\ } z \xrightarrow{\ \text{decoder}\ } \hat{x}

Training compares x^\hat{x} with xx and adjusts the weights to bring them closer. That comparison is the only feedback the model gets. Whatever zz ends up containing is whatever helps the decoder rebuild xx.

To see every computation, we use the smallest example that still means something. The input has two numbers. The latent has one.

x=[2.0, 1.0],dim⁡z=1x = [2.0,\ 1.0], \qquad \dim z = 1

Here the latent is smaller than the input, so the model has to compress. That is a common choice, not a requirement. The last chapter uses a latent that is much wider than the input.

Start with the encoder. In its simplest form it is one linear operation.

z = x @ W_encoder + b_encoder
2.01.0x(2,)@0.5−0.3Wenc(2, 1)+0.1benc(1,)=z(1,)2.0 × 0.5→1.01.0 × (−0.3)→−0.3+ bias→0.10.8

01 Autoencoder/ Encoder

A single neuron is a weighted sum.

This one line of NumPy is a complete encoder. It starts from the input vector xx, which has shape (2,).

The weight matrix Wenc\Wenc holds one weight per input dimension. Its shape (2, 1) says that it maps two numbers to one.

Wenc=[0.5−0.3]\Wenc = \begin{bmatrix} 0.5 \\ -0.3 \end{bmatrix}

The @ operator pairs each entry of xx with the matching weight. The first input meets the first weight, and the second meets the second.

Each pair is multiplied.

2.0×0.5=1.01.0×(−0.3)=−0.3\begin{aligned} 2.0 \times 0.5 &= 1.0 \\ 1.0 \times (-0.3) &= -0.3 \end{aligned}

The products are added, together with the bias benc\benc. The bias shifts the result by the same amount for every input.

1.0+(−0.3)+0.1=0.81.0 + (-0.3) + 0.1 = 0.8

The whole line reduces to one number, the latent representation of xx.

z=x Wenc+benc=0.8z = x\,\Wenc + \benc = 0.8

This is exactly what one neuron without an activation function computes. A layer is several neurons side by side, which is why WW is a matrix.

z = x @ W_encoder + b_encoder
123−112x1x2Wencevery point on the dashed line has z = 0.8−2−1012zx0.8

01 Autoencoder/ Encoder

The encoder folds a plane onto a line.

An input with two numbers is a point in a plane. Here are seven inputs, including xx. The encoder gives each of them one number.

Inputs that share a value of zz lie on a straight line at a right angle to the weight vector Wenc\Wenc. Along each of these lines, zz is constant.

So the encoder slides every point along its line until it meets the direction of Wenc\Wenc. Where the point sat along its line is forgotten.

A point's position along that direction, scaled by the length of Wenc\Wenc and shifted by the bias, is its value of zz. Seven points in two dimensions are now seven numbers.

This is not the same as deleting a coordinate. Dropping x2x_2 would be one particular choice, the direction at 0°. Training can choose any direction.

Try a few with the slider. Some directions keep the points spread apart. Others squeeze them together, which leaves the decoder little to work with.

x_hat = z @ W_decoder + b_decoder
123−112x1x2−2−1012zevery z decodes to a point on this linez @ W_decoder = [1.20, 0.40]+ b_decoder = [0.2, 0.2]xx0.8x= [2.0, 1.0]x= [1.4, 0.6]

01 Autoencoder/ Decoder

The decoder goes from one number back to two.

It has the same form as the encoder, in reverse. It starts from z=0.8z = 0.8.

Wdec\Wdec has shape (1, 2). Multiplying by zz scales its single row, so the result always lies along the same direction. Only its length, and for negative zz its sign, depends on zz.

0.8×[1.5, 0.5]=[1.2, 0.4]0.8 \times [1.5,\ 0.5] = [1.2,\ 0.4]

The bias moves the result to its final place.

x^=[1.2, 0.4]+[0.2, 0.2]=[1.4, 0.6]\begin{aligned} \hat{x} &= [1.2,\ 0.4] + [0.2,\ 0.2] \\ &= [1.4,\ 0.6] \end{aligned}

Because zz is a single number, every possible output lies on one line. Move zz to trace it. The other six inputs are decoded onto the same line.

Now compare. The input was x=[2.0, 1.0]x = [2.0,\ 1.0]. The reconstruction is x^=[1.4, 0.6]\hat{x} = [1.4,\ 0.6].

Nothing has been learned yet. The weights are numbers we picked by hand, so every reconstruction misses its input.

To improve the weights, we first need a number that says how wrong a reconstruction is.

loss = np.mean((x_hat - x) ** 2)dL_dx_hat = x_hat - x
121x1x2(−0.6)² = 0.360.16−0.6−0.4∂L / ∂x−gradientxxL = 0.260∂L / ∂x= [−0.60, −0.40]

01 Autoencoder/ Loss

The loss turns a miss into one number.

Start with the difference between the reconstruction and the input.

x^−x=[−0.6, −0.4]\hat{x} - x = [-0.6,\ -0.4]

Square each entry, so that misses in either direction count and large misses count more. Then take the mean over the two coordinates. In the figure, each squared error is the area of a square.

L=12((−0.6)2+(−0.4)2)=12(0.36+0.16)=0.26\begin{aligned} L &= \tfrac{1}{2}\big((-0.6)^2 + (-0.4)^2\big) \\ &= \tfrac{1}{2}(0.36 + 0.16) = 0.26 \end{aligned}

The loss depends only on the distance between x^\hat{x} and xx. Every point on a circle around xx has the same loss. These circles are the contour lines of the loss.

The gradient tells us how the loss changes as x^\hat{x} moves. For the mean over nn coordinates it is

∂L∂x^=2n(x^−x)=x^−x(n=2)\frac{\partial L}{\partial \hat{x}} = \frac{2}{n}(\hat{x} - x) = \hat{x} - x \quad (n = 2)

It points straight away from xx, across the contour lines. That is the direction in which the loss grows fastest.

Its negative points toward xx. A small step in that direction lowers the loss. Gradient descent repeats such small steps.

Drag x^\hat{x}, or focus it and use the arrow keys. The gradient grows as x^\hat{x} moves away from xx, and it always points away from it.

But we cannot move x^\hat{x} directly. It is computed from the weights. What we need is the gradient for each weight.

dL_dx_hat = x_hat - xdL_dW_decoder = np.outer(z, dL_dx_hat)dL_db_decoder = dL_dx_hatdL_dz = dL_dx_hat @ W_decoder.TdL_dW_encoder = np.outer(x, dL_dz)dL_db_encoder = dL_dzW_encoder -= lr * dL_dW_encoder
encdecWencbencWdecbdecxzxLforward: every value is knownbackward: from the loss toward the inputevery weight moves against its gradient

01 Autoencoder/ Backpropagation

The error travels backward.

The forward pass computed every value in this graph. Each operation knows only its own inputs and outputs.

Backpropagation lets every operation answer one local question. How much did my input contribute to the final error?

It starts at the loss, where we already know the answer.

∂L∂x^=x^−x=[−0.6, −0.4]\frac{\partial L}{\partial \hat{x}} = \hat{x} - x = [-0.6,\ -0.4]

The decoder computed x^=z Wdec+bdec\hat{x} = z\,\Wdec + \bdec. A weight's gradient is the input it multiplied, times the gradient arriving from above. The bias passes that gradient through unchanged.

∂L∂Wdec=z ∂L∂x^=0.8×[−0.6, −0.4]=[−0.48, −0.32]\begin{aligned} \frac{\partial L}{\partial \Wdec} &= z\,\frac{\partial L}{\partial \hat{x}} \\ &= 0.8 \times [-0.6,\ -0.4] \\ &= [-0.48,\ -0.32] \end{aligned}

The gradient also passes through the decoder to zz. This is the chain rule. The slope of x^\hat{x} with respect to zz is Wdec\Wdec, so

∂L∂z=∂L∂x^ Wdec⊤=(−0.6)(1.5)+(−0.4)(0.5)=−1.1\begin{aligned} \frac{\partial L}{\partial z} &= \frac{\partial L}{\partial \hat{x}}\,\Wdec^{\top} \\ &= (-0.6)(1.5) + (-0.4)(0.5) \\ &= -1.1 \end{aligned}

The sign is informative. Increasing zz would lower the loss, because it moves x^\hat{x} along the decoder line toward xx.

The same rule reaches the encoder. Its weights multiplied xx, so their gradient is xx times the gradient at zz.

∂L∂Wenc=x⊤∂L∂z=[2.01.0](−1.1)=[−2.2−1.1]\begin{aligned} \frac{\partial L}{\partial \Wenc} &= x^{\top}\frac{\partial L}{\partial z} \\ &= \begin{bmatrix} 2.0 \\ 1.0 \end{bmatrix} (-1.1) \\ &= \begin{bmatrix} -2.2 \\ -1.1 \end{bmatrix} \end{aligned}

Every weight now has a gradient. Each takes a small step against it, scaled by a learning rate η\eta.

W←W−η ∂L∂WW \leftarrow W - \eta\,\frac{\partial L}{\partial W}

With η=0.1\eta = 0.1, one step lowers the loss from 0.26 to 0.14. Repeating this step is training.

So far the model has seen one input. A useful representation has to work for many.

Z = X @ W_encoder + b_encoderX_hat = Z @ W_decoder + b_decoderfor epoch in range(40):    idx = rng.permutation(len(X))    for rows in np.split(idx, 5):        grads = gradients(X[rows])        for p, g in zip(params, grads):            p -= lr * g
X(100, 2)Z(100, 1)←xbatch 1batch 2batch 3batch 4batch 512312x1x220 rows → gradients averaged over the batch→ one update of every weight5 batches = 5 updates = 1 epoch010203040epochs0.60dataset loss, log scale0.011

01 Autoencoder/ Training

From one input to a dataset.

A dataset is a matrix with one row per observation. Here XX has 100 rows and 2 columns. Its first row is our xx.

x∈R2⟶X∈R100×2x \in \mathbb{R}^{2} \quad\longrightarrow\quad X \in \mathbb{R}^{100 \times 2}

The model does not change. The same weights are applied to every row, in a single matrix multiplication. The bias is added to each row, which NumPy calls broadcasting.

Z=X Wenc+benc,Z∈R100×1Z = X\,\Wenc + \benc, \qquad Z \in \mathbb{R}^{100 \times 1}

Before each pass over the data, the rows are shuffled. That way the updates do not see the data in the same order every time.

The shuffled rows are cut into mini-batches, here 5 batches of 20 rows.

Each batch produces one update. The loss is averaged over the rows of the batch, so the gradient is an average as well. It is noisier than the gradient over all the data, and much cheaper when the data is large.

One pass through all batches is an epoch. Here an epoch makes 5 updates.

After 40 epochs, 200 updates in all, the loss over the dataset has dropped from 0.60 to 0.011. In the code, gradients is the backward pass from the previous section, averaged over the batch.

12312x1x2dashed line, before trainingsolid line, after training012zxxx = [2.00, 1.00]z = 1.33x_hat = [1.99, 1.25]

01 Autoencoder/ Result

What did the autoencoder learn?

This data lies close to a line, with some scatter around it. It has two coordinates, but most of its variation runs in one direction.

The trained encoder gives each point one number. Points that are close together along the line get similar values of zz.

The decoder sends each zz back to a line in the plane. Training has turned and shifted that line until it runs through the data.

Hover over a point, or use the slider, to follow one input through the model. What survives in zz is the position along the line. What is lost is how far the point sits from the line, and that offset is the reconstruction error.

The model found the direction that best describes the data, without being told that it existed.

For a linear autoencoder like this one, that direction is the same one principal component analysis finds. With nonlinear layers the idea extends to curved structure.

An ordinary autoencoder maps each input to one exact point. The space between those points is never trained.

What if each input described a region of latent space instead?

h = encoder(x)mu = mu_head(h)logvar = logvar_head(h)
xencoder1.200.60z1.200.60−0.45−1.39μlog σ²log σ²σ² = exp(log σ²)σ = exp(½ log σ²)[−0.45, −1.39][0.64, 0.25][0.80, 0.50]z1z2x(1)z(1)x(2)z(2)x(3)z(3)μσ1σ2z is still two numbers

02 Variational

From a point to a region.

Take a latent space with two dimensions and three inputs. We use two dimensions so that we can draw them. Real models often use many more.

An ordinary autoencoder places each input at one point, z(1),z(2),z(3)z^{(1)}, z^{(2)}, z^{(3)}. Its decoder is trained on those points only. Nothing in training constrains what it produces for a zz in between.

A variational autoencoder encodes each input as a region instead. Every point in the region should decode to something close to the input. That pushes nearby codes to mean similar things.

The encoder now ends in two outputs. For each latent dimension it predicts a mean and a log variance. With a two-dimensional latent, that is four numbers.

μ=[μ1, μ2]log⁡σ2=[log⁡σ12, log⁡σ22]\begin{aligned} \mu &= [\mu_1,\ \mu_2] \\ \log \sigma^2 &= [\log \sigma_1^2,\ \log \sigma_2^2] \end{aligned}

These are not extra latent dimensions. They are the parameters of a Gaussian distribution over zz, and zz still has two dimensions. The mean μ\mu sets the center. The standard deviation σ\sigma sets the width along each axis.

q(z∣x)=N(μ, diag⁡(σ2))q(z \mid x) = \N\big(\mu,\ \operatorname{diag}(\sigma^2)\big)

A standard VAE assumes a diagonal covariance. The dimensions do not co-vary, which is why the region is an ellipse aligned with the axes.

The encoder predicts log⁡σ2\log \sigma^2 rather than the variance itself. A variance must be positive, but a linear layer can output any number. The exponential of any number is positive, and the log scale keeps very small and very large variances numerically stable.

log⁡σ2=[−0.45, −1.39]σ2=exp⁡(log⁡σ2)=[0.64, 0.25]σ=exp⁡(12log⁡σ2)=[0.80, 0.50]\begin{aligned} \log \sigma^2 &= [-0.45,\ -1.39] \\ \sigma^2 &= \exp(\log \sigma^2) \\ &= [0.64,\ 0.25] \\ \sigma &= \exp\big(\tfrac{1}{2}\log \sigma^2\big) \\ &= [0.80,\ 0.50] \end{aligned}

The two are easy to confuse. σ2\sigma^2 is the variance and σ\sigma is the standard deviation.

std = torch.exp(0.5 * logvar)epsilon = torch.randn_like(std)z = mu + std * epsilon
z1z2μN(0, I)εσ= [0.80, 0.50]ε= [0.90, −1.10]z= [1.92, 0.05]sampleμσzno gradient through a random draw×+μσεrandom, but outside the pathz∂z/∂μ = 1∂z/∂σ = ε

02 Variational/ Sampling

The reparameterization trick.

To decode, we need one concrete zz from the region. The obvious way is to sample it directly.

z∼N(μ,σ2)z \sim \N(\mu, \sigma^2)

But a random draw has no derivative. Backpropagation cannot tell how μ\mu or σ\sigma should change to improve the sample.

The trick moves the randomness out of the way. First, turn the predicted log variance into a standard deviation.

σ=exp⁡(12log⁡σ2)=[0.80, 0.50]\sigma = \exp\big(\tfrac{1}{2}\log\sigma^2\big) = [0.80,\ 0.50]

Then draw noise from a fixed standard normal distribution. This draw does not depend on any weight.

ε∼N(0,I),ε=[0.9, −1.1]\varepsilon \sim \N(0, I), \qquad \varepsilon = [0.9,\ -1.1]

Scale the noise by σ\sigma, one dimension at a time. The unit circle stretches into the shape of the ellipse.

σ⊙ε=[0.72, −0.55]\sigma \odot \varepsilon = [0.72,\ -0.55]

Then shift it by the mean.

z=μ+σ⊙ε=[1.92, 0.05]z = \mu + \sigma \odot \varepsilon = [1.92,\ 0.05]

The result has exactly the distribution we wanted, N(μ,σ2)\N(\mu, \sigma^2).

Now zz is an ordinary function of μ\mu and σ\sigma, with ε\varepsilon as a fixed input. Its derivatives are simple, so the gradient reaches the encoder.

∂z∂μ=1,∂z∂σ=ε\frac{\partial z}{\partial \mu} = 1, \qquad \frac{\partial z}{\partial \sigma} = \varepsilon

The randomness is still there. It has moved to an input that needs no gradient.

Draw more samples. Each lands somewhere in the ellipse, more often near μ\mu. Over many draws they fill in N(μ,σ2)\N(\mu, \sigma^2).

L=\mathcal{L} =Lrec\Lrec+ β+\ \betaLKL\LKL
z1z2N(0, I)total KL42.17KL term only, so z says nothing about xreconstruction term onlyboth terms, β = 0.0

02 Variational/ Loss

Two objectives pull against each other.

The reconstruction term asks the model to keep enough about xx to rebuild it. For real-valued data it is often the squared error.

Lrec=∥x^−x∥2\Lrec = \lVert \hat{x} - x \rVert^2

On its own it favors small regions far apart. Precise, separate codes are the easiest to decode.

The KL divergence measures how far each encoded distribution is from a shared prior, the standard normal N(0,I)\N(0, I). It keeps every input's code compatible with one common space.

LKL=12∑j(μj2+σj2−log⁡σj2−1)\LKL = \tfrac{1}{2}\sum_j \big(\mu_j^2 + \sigma_j^2 - \log\sigma_j^2 - 1\big)

The μj2\mu_j^2 term pulls each center toward the origin. The other terms are smallest when σj=1\sigma_j = 1.

With the KL term alone, the best solution is to encode every input as N(0,I)\N(0, I) itself. The KL is zero, but zz now carries no information about xx. The decoder cannot tell one input from another.

With both terms, the distributions move toward the origin and widen, but stay apart enough to identify their input. The gaps between them close, so points there decode to something plausible.

L=Lrec+β LKL\mathcal{L} = \Lrec + \beta\,\LKL

The standard VAE uses β=1\beta = 1. Larger values trade reconstruction quality for a more regular latent space.

Change β\beta to see the tradeoff. As it grows, the distributions crowd toward the prior. With a very large β\beta, real models can stop using some latent dimensions altogether.

The arrangement in this figure is drawn by hand to show the trend. It is not the result of training.

z = torch.randn(6, 2)x_new = decoder(z)
zreconstructionz ~ N(0, I)generationz1z2encoded from x

02 Variational/ Generation

After training, the encoder can go.

During training, every zz comes from an encoded input and is decoded back into that input. That is reconstruction.

The KL term has arranged the encoded regions around N(0,I)\N(0, I). So we can draw zz from that prior directly and decode it, without any input at all. That is generation.

Decoding a grid of latent points shows why this works. Nearby points decode to similar shapes, and the shapes change smoothly across the space. Far outside the prior, where training rarely placed any codes, there is less reason to trust the decoder.

The decoder in these figures is a small hand-written function standing in for a trained network, so that the geometry stays visible.

Reconstruction starts from an input.

x→μ,σ→z→x^x \to \mu, \sigma \to z \to \hat{x}

Generation starts from the prior.

z∼N(0,I)→xnewz \sim \N(0, I) \to x_{\text{new}}

Reconstruction describes an input we already have. Generation produces one we have never seen.

The first autoencoder compressed two numbers into one. A sparse autoencoder seems to do the opposite. It expands its input into more numbers than it started with.

The constraint moves from the size of the code to its activity.

h = x @ W_encoder + b_encoderz = torch.relu(h)x_hat = z @ W_decoder + b_decoderrecon = ((x_hat - x) ** 2).mean()sparsity = z.abs().sum()loss = recon + lam * sparsity
0.8the autoencoder's latent, shape (1,)0z10z21.4z30z40z50.6z60z70z8(8,)x1x212345678x2 of 8 activeL1 norm 2.00

03 Sparse

More units, fewer of them active.

Our first latent was a single number. A sparse autoencoder instead uses a latent that is wider than its input. Here, eight units encode a two-dimensional input.

For any one input, most units are exactly zero. This input uses two of the eight.

z=[0, 0, 1.4, 0, 0, 0.6, 0, 0]z = [0,\ 0,\ 1.4,\ 0,\ 0,\ 0.6,\ 0,\ 0]

The encoder ends in a ReLU, which turns negative values into zero. An inactive unit contributes nothing at all.

The decoder shows what a unit stands for. Unit jj owns a row of Wdec\Wdec, a direction djd_jin input space. The reconstruction adds up these directions, each scaled by its unit's activation.

x^=∑jzj dj+bdec\hat{x} = \sum_j z_j\, d_j + \bdec

The rows form a dictionary of directions. In this figure bdec=0\bdec = 0.

With eight directions in a two-dimensional space, there are endless ways to reach the same point. This code uses all eight units and still reconstructs xx exactly.

The eight directions are evenly spread, so they cancel out when added in equal amounts. Extra activity can hide in the code without changing the output. The reconstruction loss cannot tell the dense code from the sparse one.

A sparsity penalty breaks the tie. A common choice adds the L1 norm of zz to the loss. Among codes that reconstruct equally well, it prefers the one with the least total activity.

L=Lrec+λ LsparseLsparse=∥z∥1=∑j∣zj∣\begin{aligned} \mathcal{L} &= \Lrec + \lambda\,\Lsparse \\ \Lsparse &= \lVert z \rVert_1 = \sum_j |z_j| \end{aligned}

Other implementations keep only the kk largest activations and zero the rest, or constrain how often each unit may be active. The goal is the same.

Drag xx, or focus it and use the arrow keys. The sparse code always uses the two directions on either side of it.

The figure computes the exact code with the smallest L1 norm. A trained encoder has to approximate that code in one forward pass, and does so imperfectly.

under each shape: its 16 latent units, most of them zero

03 Sparse/ Features

What does a unit respond to?

Since each unit is active for only a few inputs, we can ask a new question of it. Which inputs make it fire?

Below each shape is its code over sixteen units. Most are zero. The shapes and codes here are constructed for illustration.

Collect the inputs where one unit is most active. For z3z_3, they all look long and thin.

Nobody told the model about elongation. The unit was useful for reconstruction, and it happens to track a property we can name.

z7z_7 responds most strongly to shapes with three lobes.

z12z_{12} is harder. Its strongest inputs share no property we can see.

This is common in practice. Some learned features are clear, some are partly clear, and some resist any short description.

A sparse feature is a learned direction, not a concept someone assigned.

A name like “long and thin” summarizes examples. It has to be tested, for instance by checking whether the unit also fires on inputs the name does not predict.

Real sparse autoencoders often work on the internal activations of another neural network, with hundreds or thousands of dimensions and many thousands of units. That is where these questions about interpretation matter most.

One structure, three constraints.

  1. Autoencoder

    x→z→x^x \to z \to \hat{x}

    goal
    A compact code that keeps what the decoder needs.
    latent
    A single point.
  2. Variational autoencoder

    x→μ,σ→z∼N(μ,σ2)→x^x \to \mu, \sigma \to z \sim \N(\mu, \sigma^2) \to \hat{x}

    goal
    A smooth, probabilistic latent space that supports generation.
    latent
    A distribution.
  3. Sparse autoencoder

    x→wide, sparse z→x^x \to \text{wide, sparse } z \to \hat{x}

    goal
    Describe each input with a few active features from a large learned dictionary.
    latent
    High-dimensional, mostly zero.

All three models pass their input through an intermediate representation, and all three learn it from one demand, to rebuild the input.

What differs is the constraint on the middle. Keep it small. Keep it smooth. Keep it sparse. Each constraint shapes what the representation can become.

← Back to blog