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.
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 , the numbers in the middle , and the attempt to rebuild the input . The vector is the latent representation. It is latent because nobody observes or labels it.
Training compares with and adjusts the weights to bring them closer. That comparison is the only feedback the model gets. Whatever ends up containing is whatever helps the decoder rebuild .
To see every computation, we use the smallest example that still means something. The input has two numbers. The latent has one.
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_encoder01 Autoencoder/ Encoder
A single neuron is a weighted sum.
This one line of NumPy is a complete encoder. It starts from the input vector , which has shape (2,).
The weight matrix holds one weight per input dimension. Its shape (2, 1) says that it maps two numbers to one.
The @ operator pairs each entry of with the matching weight. The first input meets the first weight, and the second meets the second.
Each pair is multiplied.
The products are added, together with the bias . The bias shifts the result by the same amount for every input.
The whole line reduces to one number, the latent representation of .
This is exactly what one neuron without an activation function computes. A layer is several neurons side by side, which is why is a matrix.
z = x @ W_encoder + b_encoder01 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 . The encoder gives each of them one number.
Inputs that share a value of lie on a straight line at a right angle to the weight vector . Along each of these lines, is constant.
So the encoder slides every point along its line until it meets the direction of . Where the point sat along its line is forgotten.
A point's position along that direction, scaled by the length of and shifted by the bias, is its value of . Seven points in two dimensions are now seven numbers.
This is not the same as deleting a coordinate. Dropping 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_decoder01 Autoencoder/ Decoder
The decoder goes from one number back to two.
It has the same form as the encoder, in reverse. It starts from .
has shape (1, 2). Multiplying by scales its single row, so the result always lies along the same direction. Only its length, and for negative its sign, depends on .
The bias moves the result to its final place.
Because is a single number, every possible output lies on one line. Move to trace it. The other six inputs are decoded onto the same line.
Now compare. The input was . The reconstruction is .
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 - x01 Autoencoder/ Loss
The loss turns a miss into one number.
Start with the difference between the reconstruction and the input.
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.
The loss depends only on the distance between and . Every point on a circle around has the same loss. These circles are the contour lines of the loss.
The gradient tells us how the loss changes as moves. For the mean over coordinates it is
It points straight away from , across the contour lines. That is the direction in which the loss grows fastest.
Its negative points toward . A small step in that direction lowers the loss. Gradient descent repeats such small steps.
Drag , or focus it and use the arrow keys. The gradient grows as moves away from , and it always points away from it.
But we cannot move 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_encoder01 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.
The decoder computed . A weight's gradient is the input it multiplied, times the gradient arriving from above. The bias passes that gradient through unchanged.
The gradient also passes through the decoder to . This is the chain rule. The slope of with respect to is , so
The sign is informative. Increasing would lower the loss, because it moves along the decoder line toward .
The same rule reaches the encoder. Its weights multiplied , so their gradient is times the gradient at .
Every weight now has a gradient. Each takes a small step against it, scaled by a learning rate .
With , 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 * g01 Autoencoder/ Training
From one input to a dataset.
A dataset is a matrix with one row per observation. Here has 100 rows and 2 columns. Its first row is our .
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.
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.
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 .
The decoder sends each 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 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)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, . Its decoder is trained on those points only. Nothing in training constrains what it produces for a 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.
These are not extra latent dimensions. They are the parameters of a Gaussian distribution over , and still has two dimensions. The mean sets the center. The standard deviation sets the width along each axis.
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 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.
The two are easy to confuse. is the variance and is the standard deviation.
std = torch.exp(0.5 * logvar)epsilon = torch.randn_like(std)z = mu + std * epsilon02 Variational/ Sampling
The reparameterization trick.
To decode, we need one concrete from the region. The obvious way is to sample it directly.
But a random draw has no derivative. Backpropagation cannot tell how or 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.
Then draw noise from a fixed standard normal distribution. This draw does not depend on any weight.
Scale the noise by , one dimension at a time. The unit circle stretches into the shape of the ellipse.
Then shift it by the mean.
The result has exactly the distribution we wanted, .
Now is an ordinary function of and , with as a fixed input. Its derivatives are simple, so the gradient reaches the encoder.
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 . Over many draws they fill in .
02 Variational/ Loss
Two objectives pull against each other.
The reconstruction term asks the model to keep enough about to rebuild it. For real-valued data it is often the squared error.
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 . It keeps every input's code compatible with one common space.
The term pulls each center toward the origin. The other terms are smallest when .
With the KL term alone, the best solution is to encode every input as itself. The KL is zero, but now carries no information about . 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.
The standard VAE uses . Larger values trade reconstruction quality for a more regular latent space.
Change to see the tradeoff. As it grows, the distributions crowd toward the prior. With a very large , 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)02 Variational/ Generation
After training, the encoder can go.
During training, every comes from an encoded input and is decoded back into that input. That is reconstruction.
The KL term has arranged the encoded regions around . So we can draw 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.
Generation starts from the prior.
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 * sparsity03 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.
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 owns a row of , a direction in input space. The reconstruction adds up these directions, each scaled by its unit's activation.
The rows form a dictionary of directions. In this figure .
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 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 to the loss. Among codes that reconstruct equally well, it prefers the one with the least total activity.
Other implementations keep only the largest activations and zero the rest, or constrain how often each unit may be active. The goal is the same.
Drag , 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.
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 , 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.
responds most strongly to shapes with three lobes.
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.
Autoencoder
- goal
- A compact code that keeps what the decoder needs.
- latent
- A single point.
Variational autoencoder
- goal
- A smooth, probabilistic latent space that supports generation.
- latent
- A distribution.
Sparse autoencoder
- 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.