Skip to content

Variational Autoencoders (VAE)

The previous page ended on a specific failure: an autoencoder’s code space has no shape, so sampling a random code and decoding it produces nothing. A VAE fixes that with two changes — the encoder outputs a distribution instead of a point, and the loss adds a term pulling those distributions towards a standard normal.

Both changes have a dial, and the dial has two failure modes at its ends. Measured on MNIST with a 2-dimensional latent:

β (weight on KL)Reconstruction MSEKL (nats)Spread of the meansMean posterior varianceActive dimensions
00.0433183.33710.67940.00012 of 2
0.50.04476.0331.19800.00572 of 2
10.04505.3661.08920.01042 of 2
40.04773.2360.89500.04012 of 2
200.06170.3180.45190.74591 of 2

At β = 0 it is an autoencoder with a noisy encoder and a latent 10× wider than the prior. At β = 20 the latent carries almost nothing. The useful range is narrow and the ends fail in opposite directions.

  • Why the encoder outputs a mean and a log-variance, and what the reparameterisation trick is for.
  • The two loss terms, and the measured trade between them: reconstruction 0.0433 → 0.0617 as KL falls 183.337 → 0.318.
  • Posterior collapse, measured: at β = 20 only 1 of 2 latent dimensions stays active and the posterior variance rises to 0.7459 (the prior’s is 1.0).
  • Why β = 1 produces an aggregate posterior that actually matches the prior — mean distance from the origin 1.2674 against 1.2533 expected.
  • What the latent space looks like, and why interpolation works here and not in a plain autoencoder.
L=Eq(zx)[logp(xz)]reconstruction+βDKL ⁣(q(zx)    N(0,I))keep the code space usable\mathcal{L} = \underbrace{\mathbb{E}_{q(z|x)}\left[-\log p(x|z)\right]}_{\text{reconstruction}} + \beta \cdot \underbrace{D_{\mathrm{KL}}\!\left(q(z|x)\;\|\;\mathcal{N}(0, I)\right)}_{\text{keep the code space usable}}

The second term is the whole difference. It penalises encoders whose output distribution strays from a standard normal, which makes the code space dense — every point near the origin decodes to something plausible, because the encoder was pushed to use exactly that region.

The encoder returns a distribution, not a point
mean = keras.layers.Dense(latent)(x)
log_variance = keras.layers.Dense(latent)(x)
 
class Sampler(keras.layers.Layer):
    def call(self, inputs):
        mean, log_variance = inputs
        noise = tf.random.normal(tf.shape(mean))
        return mean + tf.exp(0.5 * log_variance) * noise      # <- reparameterisation

Sampling is not differentiable, but this form of it is: the randomness sits in noise, which has no parameters, and the gradient flows through mean and log_variance as ordinary tensors. That is the reparameterisation trick, and without it the encoder could not be trained by backpropagation at all.

The KL term has a closed form for a diagonal Gaussian against a standard normal, which is why it is one line rather than a Monte-Carlo estimate:

DKL=12j(1+logσj2μj2σj2)D_{\mathrm{KL}} = -\tfrac{1}{2}\sum_{j}\left(1 + \log\sigma_j^2 - \mu_j^2 - \sigma_j^2\right)
figure MNIST, 2-D latent, 25 epochs matplotlib
Two panels. Left: reconstruction MSE against the KL weight, rising from 0.0433 at beta 0 to 0.0617 at beta 20. Right: KL divergence falling steeply from 183.337 at beta 0 to 0.318 at beta 20, with a second line showing the spread of the encoder means collapsing from 10.68 to 0.45. Two panels. Left: reconstruction MSE against the KL weight, rising from 0.0433 at beta 0 to 0.0617 at beta 20. Right: KL divergence falling steeply from 183.337 at beta 0 to 0.318 at beta 20, with a second line showing the spread of the encoder means collapsing from 10.68 to 0.45.
Reconstruction gets monotonically worse as the KL weight rises — that is the price of a usable code space, and at beta 1 it costs 0.0017 MSE against the unregularised version. The right panel shows what is being bought: at beta 0 the KL is 183.337 nats and the encoder means have a standard deviation of 10.68, so the codes live in a region the prior never visits. Sampling from the prior there is meaningless, exactly as on the previous page.

The interesting column is mean posterior variance. At β = 0 it is 0.0001 — the encoder has learned to make its distributions nearly deterministic, because noise only hurts reconstruction. As β rises, the variances grow towards the prior’s 1.0, and at β = 20 they reach 0.7459: the encoder is now emitting nearly the prior regardless of its input, which is what posterior collapse means. One of the two latent dimensions has stopped varying with the input at all.

figure beta = 1 matplotlib
Two panels. Left: a scatter plot of 3,000 encoder means in two dimensions, coloured by digit class, forming a roughly circular cloud centred near the origin with visible class regions — 1s at the top right, 0s at the left. Right: histograms of distance from the origin for the encoder means and for samples from a standard normal, which overlap closely. Two panels. Left: a scatter plot of 3,000 encoder means in two dimensions, coloured by digit class, forming a roughly circular cloud centred near the origin with visible class regions — 1s at the top right, 0s at the left. Right: histograms of distance from the origin for the encoder means and for samples from a standard normal, which overlap closely.
The right panel is the check that matters and is usually skipped: the aggregate posterior — the distribution of all encoder means together — should look like the prior, and here it does. Mean distance from the origin is 1.2674 against 1.2533 expected for a 2-D standard normal, and the aggregate standard deviation is 1.0892 against 1.0. That agreement is why sampling from the prior produces digits rather than mush.

Per-digit centres from the same run show the structure the KL term buys:

DigitCentreSpread
1(+2.29, −1.05)1.83
0(−1.58, +0.42)1.22
7(+0.83, +1.69)1.03
6(−1.06, −0.88)0.78
3(+0.03, +0.04)0.35

Digits 1 and 0 — the most visually distinctive — get pushed to the edges, while 3, 5 and 8 cluster near the origin with tiny spreads, which is precisely where the decoder’s samples are most ambiguous. A 2-dimensional latent cannot separate ten classes, and this table shows exactly how it fails.

figure The decoder over the prior, and a walk between two digits matplotlib
Top: a 12 by 12 grid of decoded digits spanning the latent space from -2.5 to 2.5 in both dimensions, with recognisable digit types occupying contiguous regions and smooth morphing between them. Bottom: a strip of ten images interpolating between two encoded digits, each intermediate frame a plausible digit-like shape. Top: a 12 by 12 grid of decoded digits spanning the latent space from -2.5 to 2.5 in both dimensions, with recognisable digit types occupying contiguous regions and smooth morphing between them. Bottom: a strip of ten images interpolating between two encoded digits, each intermediate frame a plausible digit-like shape.
Every cell of the grid is a point sampled from the prior and decoded — no encoder involved. That works only because the KL term forced the encoder to use this region. The bottom strip is the property a plain autoencoder lacks: interpolating between two codes passes through plausible images rather than through empty space, because the code distribution is dense and connected.

Every VAE sample on this page is smooth, and it is not a bug in the implementation:

  • The reconstruction term is a per-pixel likelihood. Its optimum is the conditional mean of all plausible images given the code, and an average of several sharp digits is a blurry digit.
  • The 2-dimensional latent cannot carry enough information to disambiguate, so the conditional distribution is genuinely broad — the table above shows five digit classes sharing the region near the origin.
  • The KL term further pushes codes together, which increases that overlap.

GANs replace the per-pixel likelihood with a learned discriminator and produce sharp samples instead — along with a completely different set of failure modes, measured on the next page.

diagram Diagram mermaid
sketch The reparameterisation trick p5.js
Drag the mean and the log-variance. The blue cloud is what the encoder emits for one input; the dashed circle is the prior the KL term pulls it towards.
sketch The measured table, ranked p5.js
Click a column to rank every row by it. The bars are that column's values and the highest and lowest are computed from the numbers, not written in.
  • Setting β = 0 and calling it a VAE. KL 183.337 and encoder means with sd 10.68 — the prior is irrelevant and sampling from it fails.
  • Setting β too high. At 20 the KL fell to 0.318, one of two dimensions went inactive and reconstruction lost 0.0184 MSE.
  • Not checking the aggregate posterior. The per-example KL can look fine while the combined distribution of means does not match the prior; here it did, at sd 1.0892 against 1.0.
  • Sampling without the reparameterisation trick. tf.random.normal(mean, sd) is not differentiable with respect to mean; mean + exp(0.5·logvar)·noise is.
  • Expecting sharp samples from a per-pixel likelihood. Its optimum is the conditional mean, so blur is the objective working correctly.
  • Using a 2-D latent for ten classes and blaming the model. Five digit classes shared the region near the origin with spreads under 0.5.
  • Overriding metrics or naming a method _losses in a Keras 3 subclass. Both are taken; the second fails at the first training step with 'TrackedList' object is not callable.
  • A VAE adds a distributional encoder plus a KL term; the reparameterisation trick is what makes the sampling differentiable.
  • Measured trade: reconstruction MSE 0.0433 → 0.0617 as β goes 0 → 20, with KL falling 183.337 → 0.318.
  • β = 0 leaves encoder means at sd 10.68 — an autoencoder with a noisy encoder.
  • β = 20 collapses the posterior: 1 of 2 dimensions active, mean posterior variance 0.7459 against the prior’s 1.0.
  • At β = 1 the aggregate posterior matches the prior — mean distance 1.2674 against 1.2533 expected — which is why prior samples decode to digits.
  • Samples are blurry because a per-pixel likelihood’s optimum is the conditional mean.

Replace the per-pixel likelihood with a learned discriminator and the samples get sharp — along with a new set of ways to fail: Generative Adversarial Networks (GANs).

pch.quizTag pch.quizDefaultTitle
  1. What does the reparameterisation trick solve?

    pch.quizShowAnswer

    B — It makes sampling differentiable — writing z = mean + exp(0.5·logvar)·noise puts the randomness in a parameter-free tensor, so gradients flow through the mean and variance — Sampling directly from N(mean, sd) gives no gradient path back to mean or sd, so the encoder could not be trained by backpropagation.

  2. At beta = 0 the KL divergence was 183.337 nats and the encoder means had standard deviation 10.68. What kind of model is that?

    pch.quizShowAnswer

    B — An autoencoder with a noisy encoder — nothing pulls the codes towards the prior, so sampling from the prior lands nowhere near the region the encoder uses — Its reconstruction is the best of the sweep (0.0433) precisely because it ignores the constraint that makes generation possible.

  3. At beta = 20, one of two latent dimensions became inactive and the mean posterior variance rose to 0.7459. What is that called and why does it happen?

    pch.quizShowAnswer

    B — Posterior collapse — the KL term dominates, so the cheapest solution is for the encoder to output the prior regardless of its input, and the latent stops carrying information — The prior's variance is 1.0, so a posterior variance approaching it means the encoder has stopped distinguishing inputs. Reconstruction rose to 0.0617 as a result.

  4. Why check the aggregate posterior — the distribution of all encoder means together — rather than just the loss?

    pch.quizShowAnswer

    B — Because prior sampling only works if the combined distribution of codes matches the prior; here mean distance from the origin was 1.2674 against 1.2533 expected for a 2-D standard normal — Per-example KL can be small while the aggregate distribution still has gaps or the wrong scale — and it is the aggregate that determines whether decoded prior samples look real.

  5. Why are VAE samples blurry?

    pch.quizShowAnswer

    B — A per-pixel reconstruction likelihood is optimised by the conditional mean of all plausible images for that code — and averaging several sharp digits gives a blurry one — The 2-D latent makes it worse by leaving genuine ambiguity: five digit classes shared the region near the origin with spreads under 0.5.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading