← All posts

Foundations

Post 12 of 15

Autoencoders and VAEs

Posts in this series · 15

So far in this series every network has had a teacher: a label to match, a next word to predict. This post is about a network whose teacher is the input itself. It looks at a picture, writes down a few numbers, and then has to redraw the picture from those numbers alone. That network is an autoencoder.

It sounds pointless (why copy something you already have?) until you notice the catch: the few numbers in the middle are far fewer than the numbers in the picture. To succeed, the network must find what matters and throw the rest away. And once we change it slightly into a variational autoencoder (VAE), those few numbers become a space we can pick from at random to make new data.

This is not a detour from speech. The codec under Pocket TTS is a VAE: it turns every 80 milliseconds of audio into 32 numbers, and the language model then predicts those numbers (Pocket TTS post, Mimi post). By the end of this post you will know why it is a VAE and not a plain autoencoder.

The whole model at a glance

Before taking it apart, here is a whole VAE (variational autoencoder) at work, on the small model this post trains later: 5 × 5 pixel glyphs (a vertical bar, a horizontal bar, a ring and a cross) squeezed to a code of just 2 numbers. Step through it once now. Every piece (the cloud of codes, the sampling trick, the two losses) gets its own section below, and it is worth coming back here at the end.

Two paths use the same parts. Training runs the whole chain: an image goes in, the encoder turns it into a small cloud of possible codes, one random code is drawn from the cloud, the decoder rebuilds an image from it, and a loss compares the rebuilt image with the original. Generating keeps only the decoder and feeds it a random code.

A VAE: image, encoder, mean and spread, sampled code, decoder, rebuilt image and losses
Path
Input glyph (training)
Random draw
Figure 1. A VAE from input to output. Training: image → encoder → μ and σ → z = μ + σε → decoder → rebuilt image, scored by the reconstruction and KL losses. Switch to Generating to see the decoder alone turn a random code into a new glyph. All numbers come from the trained glyph model used later in this post; New ε draws a different random code.

Compression as a game of telephone

Imagine describing a friend’s face over the phone using only three numbers. You might say height of the forehead, width of the smile, darkness of the hair. Your listener draws a face from those three numbers. If the drawing looks like your friend, your three numbers were well chosen.

An autoencoder is two networks playing this game:

Both are trained together on one goal: make x^\hat x as close to xx as possible. The score for that is the reconstruction loss. For numbers it is usually the squared error from the loss post, added up over every entry:

Lrec=∑j(x^j−xj)2L_{\text{rec}} = \sum_{j} (\hat x_j - x_j)^2

In words: for every entry of the input, take the gap between the rebuilt value and the true one, square it, and add them all.

Everyday picture: you photocopy a page through a very narrow slot; the loss counts how far each letter of the copy is from the original. For black-and-white pixels, cross-entropy from the loss post is the other common choice: each pixel becomes a yes-or-no guess.

Notice there is no label anywhere. The data supplies its own answer key. That is why autoencoders were one of the first ways to learn useful features from unlabelled data: Hinton and Salakhutdinov showed in 2006 that a deep autoencoder could squeeze images of digits and faces to as few as 30 numbers and rebuild them better than the classic linear method we meet next.

A linear autoencoder by hand

The smallest possible autoencoder: inputs are points on a flat page (two numbers each), the code is one number, and both halves are linear.

Our six points are A(4, 3), B(3, 1), C(1, 2) and their mirror images (−4, −3), (−3, −1), (−1, −2). Their average is (0, 0), which keeps the arithmetic clean.

Encoder. One weight per input coordinate, no bias:

z=w1x1+w2x2z = w_1 x_1 + w_2 x_2

In words: the code is a weighted sum of the two coordinates. Take w=(0.8,0.6)w = (0.8, 0.6). This is an arrow of length 1, since 0.82+0.62=0.64+0.36=10.8^2 + 0.6^2 = 0.64 + 0.36 = 1.

Decoder. Multiply the code back out along the same arrow:

x^=(z×w1, z×w2)\hat x = (z \times w_1,\ z \times w_2)

In words: walk zz steps along the arrow ww, starting from the origin.

Everyday picture: the encoder is a sun overhead that casts each point’s shadow onto a straight stick. The code is how far along the stick the shadow lands. The decoder can only put the point back somewhere on the stick.

Work point B(3, 1):

  1. Encode: z=0.8×3+0.6×1=2.4+0.6=z = 0.8 \times 3 + 0.6 \times 1 = 2.4 + 0.6 = 3.
  2. Decode: x^=(3×0.8, 3×0.6)=(2.4,1.8)\hat x = (3 \times 0.8,\ 3 \times 0.6) = (2.4, 1.8).
  3. Error: (2.4−3)2+(1.8−1)2=0.36+0.64=(2.4 - 3)^2 + (1.8 - 1)^2 = 0.36 + 0.64 = 1.

Point A(4, 3): z=3.2+1.8=5z = 3.2 + 1.8 = 5, x^=(4,3)\hat x = (4, 3), error 0. A already sits on the stick. Point C(1, 2): z=0.8+1.2=2z = 0.8 + 1.2 = 2, x^=(1.6,1.2)\hat x = (1.6, 1.2), error 0.36+0.64=10.36 + 0.64 = 1. The mirror points give the same errors, so the average over all six is (0+1+1+0+1+1)/6=(0 + 1 + 1 + 0 + 1 + 1) / 6 = 0.667.

What if we pick a worse stick? With w=(1,0)w = (1, 0) (keep only the first coordinate), the errors are 9, 1, 4 for A, B, C, average 4.667. With w=(0,1)w = (0, 1), average 8.667. Training moves ww the way gradient descent always does, toward the stick with the smallest average error.

Points squeezed onto a line through a one-number code and rebuilt
Point
Figure 2. Squeeze to one number and back. Press Step to encode, decode and score each point. Turn the stick with the slider to see the average error change; “Best angle” jumps to the PCA direction.

The best stick is not a mystery. It is the direction along which the points are most spread out, called the first principal component; finding it is principal component analysis (PCA), a method from 1901. For our six points it is about (0.816,0.578)(0.816, 0.578), and its average error is 0.657, just below our 0.667. Our hand-picked (0.8,0.6)(0.8, 0.6) was nearly optimal.

This is a known result: a linear autoencoder with squared error, trained well, learns the same subspace as PCA. So a linear autoencoder adds nothing new. The interest starts when encoder and decoder are deep, nonlinear networks: then the “stick” can bend into a curve, which is what Hinton and Salakhutdinov’s deep autoencoder did, and why it beat PCA on their images.

How big should the bottleneck be

If the code is smaller than the input, the autoencoder is undercomplete. It cannot copy everything, so it must choose. That is the useful case.

If the code is as large as the input or larger, it is overcomplete. Now there is a cheap way to score zero loss: copy the input through unchanged. Nothing is learned. Overcomplete autoencoders need some other pressure to stay useful.

One such pressure is noise. A denoising autoencoder (Vincent and colleagues, 2008) corrupts the input before the encoder sees it, for example by setting a random fraction of the pixels to zero, but scores the decoder against the clean input. Copying no longer works, because the input it sees is wrong. To fill in the missing pixels it has to learn what real inputs look like. If you have read the regularisation post, this is a close cousin of dropout, applied to the input.

The latent space and its holes

Every input lands somewhere in the code space. With a two-number code, we can draw that space as a map, and each training example is a dot on it. This map is the latent space.

Here is the question that matters for generation: what happens if we pick a point on that map that no training example landed on, and decode it?

For a plain autoencoder, often nothing good. Training only ever asked the decoder to handle the codes of real examples. Nothing in the loss cares about the empty regions between them, so the decoder’s output there is whatever its weights happen to produce: a smear, a blend of two shapes that matches neither, or noise. The encoder also has no reason to keep codes near the origin or packed together, so clusters can sit far apart with wide gaps. Those gaps are the holes.

A map of codes; clicking a point decodes it into a small glyph
Model
Sample
Figure 3. Decode this point. A plain autoencoder, two-number code, trained on twelve 5 × 5 glyphs (seeded). Click inside a cluster: a clean glyph. Click in a gap: garbage. Switch to the VAE (same network, trained with the VAE loss) and click the same spots. Over 200 random draws from N(0, 1), a plain-autoencoder draw decodes on average 2.43 squared pixels from the nearest real glyph; a VAE draw only 0.52.

So a plain autoencoder is a good compressor and a bad generator. To generate, we need a code space where every reasonable point decodes to something real, and we need to know which points are “reasonable” so we can pick them at random. The VAE solves both.

The VAE idea: codes as little clouds

Kingma and Welling (2013, published at ICLR 2014) and, independently, Rezende, Mohamed and Wierstra (2014) proposed the same fix from a probabilistic angle. The intuition is simple.

Instead of mapping an input to one point, the encoder maps it to a small cloud: a bell curve with a centre and a width. For each code number it outputs two things:

During training we do not decode the centre. We draw a random point from the cloud and decode that. The decoder must therefore rebuild the input well from any point near μ\mu, not just μ\mu itself.

Everyday picture: instead of marking your house on a map with a pin, you mark it with a coin. Anyone who lands anywhere under the coin must still find your house. Neighbouring coins overlap, so the region between two houses is covered by both, and the decoder learns to make sensible in-between outputs there. The holes start to fill.

But the encoder could cheat: make every cloud tiny (σ\sigma near 0, back to a pin) or spread clouds far apart. A second loss term stops both, and we will reach it after one technical problem.

Sampling with the reparameterisation trick

Training needs a gradient for μ\mu and σ\sigma. But “draw a random point from the cloud” is not a calculation with a slope. If a dice roll sits in the middle of the network, backpropagation has nothing to differentiate.

The trick is to move the randomness out of the way. Draw plain noise from a fixed bell curve first, then shift and stretch it:

z=μ+σ×ε,ε∼N(0,1)z = \mu + \sigma \times \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, 1)

In words: take a standard random number, stretch it by the spread, and slide it to the centre.

Everyday picture: a dart thrower with a fixed wobble. The wobble (ε\varepsilon) is the thrower’s own; you only choose where to aim (μ\mu) and how far back to stand (σ\sigma). Once the dart lands, you can say exactly how moving the aim would have moved it.

Worked example. Take μ=0.8\mu = 0.8, σ=0.5\sigma = 0.5, and suppose the draw is ε=−0.6\varepsilon = -0.6.

  1. Stretch: 0.5×(−0.6)=−0.30.5 \times (-0.6) = -0.3.
  2. Shift: z=0.8+(−0.3)=z = 0.8 + (-0.3) = 0.5.

Now the gradient. Use the simplest decoder, x^=z\hat x = z, with target x=1x = 1, and loss (z−1)2(z - 1)^2.

  1. Loss slope at zz: 2×(0.5−1)=2 \times (0.5 - 1) = −1.
  2. Since z=μ+σεz = \mu + \sigma \varepsilon, nudging μ\mu by a little moves zz by the same amount. So the slope for μ\mu is −1×1=-1 \times 1 = −1.
  3. Nudging σ\sigma moves zz by ε\varepsilon times as much. So the slope for σ\sigma is −1×(−0.6)=-1 \times (-0.6) = 0.6.
  4. A gradient step with learning rate 0.1: μ→0.8−0.1×(−1)=\mu \to 0.8 - 0.1 \times (-1) = 0.9, σ→0.5−0.1×0.6=\sigma \to 0.5 - 0.1 \times 0.6 = 0.44.

The centre moved toward the target, and the cloud shrank, because this particular draw landed on the wrong side. Change ε\varepsilon to +0.6+0.6 and z=1.1z = 1.1, overshooting the target: the slope for σ\sigma is again positive, 2×0.1×0.6=0.122 \times 0.1 \times 0.6 = 0.12. Reconstruction alone always wants a smaller cloud. Keep that in mind.

The randomness ε\varepsilon is treated as a fixed input, like data. Every arrow from the loss back to μ\mu and σ\sigma is ordinary multiplication and addition, so backpropagation from the first post works unchanged. The Mimi codec uses exactly this line, per channel, for its 32 latent channels (Mimi post).

A small computation graph where a random draw is scaled and shifted, and gradients flow back to the centre and spread
Figure 4. Moving the dice out of the way. Step through one sample and one backward pass. The gradient reaches μ and σ; it never needs to pass through the random draw.

The KL term: keeping clouds near the middle

Now the second loss term. We want every cloud to stay close to one shared reference cloud, the standard normal N(0,1)\mathcal{N}(0, 1), called the prior. This does two jobs: it keeps the clouds wide (no pins) and keeps them near the origin (no far-flung clusters). If every input’s cloud roughly overlaps the standard normal, then the whole latent space is covered, and a random draw from N(0,1)\mathcal{N}(0, 1) lands in a region the decoder knows.

We need a number that says how different two bell curves are. That number is the Kullback-Leibler divergence, or KL divergence, after Solomon Kullback and Richard Leibler.

KL in plain words

KL asks: if points really come from cloud qq, how surprised would someone be, on average, who believed they came from cloud pp instead? It is the average, over samples from qq, of the log of how much more likely qq says each sample is than pp does:

KL(q∥p)=average over z from q of ln⁡q(z)p(z)\mathrm{KL}(q \Vert p) = \text{average over } z \text{ from } q \text{ of } \ln \frac{q(z)}{p(z)}

It is 0 exactly when the two curves are identical, and positive otherwise. It is not symmetric: KL(q∥p)\mathrm{KL}(q \Vert p) and KL(p∥q)\mathrm{KL}(p \Vert q) usually differ.

The formula for one-number codes

For two bell curves, that average can be done once, with algebra, and the result is short. The log of a bell curve’s height is a constant minus a squared distance:

ln⁡q(z)=−ln⁡σ−(z−μ)22σ2+c,ln⁡p(z)=−z22+c\ln q(z) = -\ln \sigma - \frac{(z-\mu)^2}{2\sigma^2} + c, \qquad \ln p(z) = -\frac{z^2}{2} + c

where cc is the same constant in both. Subtract, and average over zz drawn from qq. Two facts do the work: on average (z−μ)2(z - \mu)^2 is σ2\sigma^2 (that is what spread means), and on average z2z^2 is μ2+σ2\mu^2 + \sigma^2 (the squared centre plus the spread). So

KL=−ln⁡σ−12+μ2+σ22=12(μ2+σ2−ln⁡σ2−1)\mathrm{KL} = -\ln \sigma - \frac{1}{2} + \frac{\mu^2 + \sigma^2}{2} = \frac{1}{2}\Big(\mu^2 + \sigma^2 - \ln \sigma^2 - 1\Big)

In words: half of (centre squared, plus spread squared, minus the log of spread squared, minus one).

With a code of many numbers, each channel gets its own μ\mu and σ\sigma and the KL is the sum over channels. This is the formula in the Mimi post, summed over its 32 channels.

Worked for a few clouds

μ\muσ\sigmaμ2\mu^2σ2\sigma^2−ln⁡σ2-\ln \sigma^2bracketKL
010100+1+0−1=00 + 1 + 0 - 1 = 00
214104+1+0−1=44 + 1 + 0 - 1 = 42
00.500.251.3860.25+1.386−1=0.6360.25 + 1.386 - 1 = 0.6360.318
0204−1.3864−1.386−1=1.6144 - 1.386 - 1 = 1.6140.807
10.510.251.3861+0.25+1.386−1=1.6361 + 0.25 + 1.386 - 1 = 1.6360.818
00.100.014.6050.01+4.605−1=3.6150.01 + 4.605 - 1 = 3.6151.808

Read down the table. Moving the centre to 2 costs 2. Halving the width costs 0.318, doubling it costs 0.807, and squeezing it to a tenth costs 1.808, more than moving it off-centre by 3.6≈1.9\sqrt{3.6} \approx 1.9 would. Narrow clouds are expensive.

An encoder's bell curve against the standard normal, with the KL formula filled in
Figure 5. How far is this cloud from the standard normal? Drag μ and σ and watch each term of the bracket. Presets load the six rows of the table.

The VAE loss and its tug of war

Put the two terms together. For one input:

L=Lrec+β×KLL = L_{\text{rec}} + \beta \times \mathrm{KL}

In words: rebuild well, and keep your cloud close to the standard normal; β\beta (beta) sets how much the second matters. The original VAE uses β=1\beta = 1. (Formally, this sum is the negative of the evidence lower bound, or ELBO, which Kingma and Welling derive from probability theory; we skip that derivation here.)

The two terms pull in opposite directions. Reconstruction wants pins: a tiny σ\sigma means the decoder always sees nearly the same zz, so it can rebuild precisely. KL wants every cloud to be the standard normal, which would make all inputs look the same to the decoder.

A toy where we can see the whole tug of war: a one-number code, the decoder x^=z\hat x = z, target x=1x = 1, squared error. Averaged over the random ε\varepsilon, the reconstruction loss has a closed form:

average Lrec=(μ−x)2+σ2\text{average } L_{\text{rec}} = (\mu - x)^2 + \sigma^2

In words: the error of the centre, plus the extra error from wobbling around it. Check at μ=0.8\mu = 0.8, σ=0.5\sigma = 0.5: (0.8−1)2+0.25=0.04+0.25=(0.8 - 1)^2 + 0.25 = 0.04 + 0.25 = 0.29. The KL for that cloud is 12(0.64+0.25+1.386−1)=\frac{1}{2}(0.64 + 0.25 + 1.386 - 1) = 0.638. Total at β=1\beta = 1: 0.928.

Setting both slopes to zero gives the best cloud for any β\beta: μ=2x/(2+β)\mu = 2x / (2 + \beta) and σ2=β/(2+β)\sigma^2 = \beta / (2 + \beta).

β\betabest μ\mubest σ\sigmareconstructionKL
0100infinite
0.50.80.4470.240.725
10.6670.5770.4440.438
40.3330.8161.1110.092
200.0910.9531.7360.006

At β=0\beta = 0 the VAE becomes a plain autoencoder: a pin exactly on the target. As β\beta grows the cloud slides to 0 and widens to 1, and the code tells the decoder less and less about xx.

An encoder cloud with sampled points, its reconstruction loss, KL and total
Optimum
Figure 6. One cloud, two pulls. Toy: one-number code, decoder x̂ = z, target 1. Drag μ and σ to minimise the total; then press “Jump to best” and change β.
The trade-off curve between reconstruction loss and KL as beta changes
Figure 7. The price of a tidy space. Each point is the best cloud for one β in the toy above. Lower left is impossible: you cannot have both a perfect rebuild and zero KL.

Turning beta up: β-VAE

Higgins and colleagues (2017) studied β>1\beta > 1 on purpose and called it the β-VAE. A strong pull toward independent standard normals pushes the model to use each code number for one separate factor of the data (in their image examples, things like position, size or rotation), which they call a disentangled code. The price is blurrier reconstructions, the same trade we saw in the table.

Turning it too far: posterior collapse

The extreme of that table has a name. If the decoder is powerful enough to produce good outputs without help, for example a language model that predicts each word from the previous words, the cheapest solution is to set every cloud to exactly N(0,1)\mathcal{N}(0, 1), pay zero KL, and let the decoder ignore zz completely. The code then carries no information. This is posterior collapse (“posterior” is the formal name for the encoder’s cloud). Bowman and colleagues (2016) hit it when training VAEs on sentences, and countered it by starting with β=0\beta = 0 and raising it slowly during training (KL annealing), and by dropping some of the decoder’s input words so it had to rely on zz.

This is a different failure from the one in Zero loss, empty representation. There, an encoder outputs the same vector for every input because the training objective allows it. Here the encoder’s clouds all match the prior and the decoder stops listening. Both end with a code that says nothing about its input, but the mechanism differs.

Generating new things

Once trained, the VAE’s encoder can be set aside. To make something new:

  1. Draw zz from the prior, N(0,1)\mathcal{N}(0, 1) in every code number.
  2. Decode it.

That is the whole generator. It works because the KL term made the encoder’s clouds, put together, cover roughly the region where the prior puts its draws. A plain autoencoder offers no such promise: there is no known distribution to draw from, and draws land in holes.

When the VAE is used purely as a compressor, as in a codec, the noise is not needed after training: the encoder’s mean μ\mu is used as the code. That is what the Mimi post describes for the Pocket TTS codec: “At inference the noise is dropped and the mean is used.”

Walking between two codes

A good latent space lets you walk from one code to another and see a smooth change. Take the codes of two inputs, zAz_A and zBz_B, and decode points on the straight line between them:

z(t)=(1−t)×zA+t×zBz(t) = (1 - t) \times z_A + t \times z_B

In words: at t=0t = 0 you are at A, at t=1t = 1 at B, and in between you are that fraction of the way along.

Example with two-number codes zA=(−1.2,0.4)z_A = (-1.2, 0.4) and zB=(1.0,−0.8)z_B = (1.0, -0.8). At t=0.25t = 0.25: 0.75×(−1.2)+0.25×1.0=−0.9+0.25=−0.650.75 \times (-1.2) + 0.25 \times 1.0 = -0.9 + 0.25 = -0.65, and 0.75×0.4+0.25×(−0.8)=0.3−0.2=0.10.75 \times 0.4 + 0.25 \times (-0.8) = 0.3 - 0.2 = 0.1, so z=(−0.65,0.1)z = (-0.65, 0.1). At t=0.5t = 0.5: (−0.1,−0.2)(-0.1, -0.2). At t=0.75t = 0.75: (0.45,−0.5)(0.45, -0.5).

In a VAE the decoded steps change gradually from A to B, through blends of the two. A plain autoencoder makes no such promise. In the toy of Figure 8 its steps snap from one shape to the other with no in-between at all, because its codes sit far apart and the decoder draws a sharp border between them; in other models the middle of the line can land in a hole and decode to garbage. The same straight-line idea, between noise and a real latent, is the backbone of flow matching in the Pocket TTS post.

Two rows of decoded glyphs along a straight line between two codes, plain autoencoder and VAE
From
To
Figure 8. Walking across the map. The same two glyphs, the same straight line, two models. On this toy the plain autoencoder snaps from one shape to the other; the VAE passes through a soft blend. Pick any two glyphs as the endpoints.

The discrete cousin: VQ-VAE

There is a second way to tidy a code space: forbid in-between values altogether. The vector-quantised VAE (VQ-VAE) of van den Oord, Vinyals and Kavukcuoglu (2017) keeps a codebook, a short list of learned vectors, and replaces each encoder output with the nearest entry. The code is then a list of whole numbers (which entries were picked), and generation is done by a separate model that predicts those numbers, like predicting words.

Rounding to the nearest entry has no useful slope, so VQ-VAE passes the decoder’s gradient straight through the rounding step to the encoder. All of this, plus the residual version that stacks several codebooks, is built step by step in the RVQ post, including the straight-through trick.

Latent spaces in speech

Both kinds of bottleneck appear in Richard’s speech posts.

Counting the parameters

A VAE is two ordinary networks, so its count is the sum of their dense layers, each nin×nout+noutn_{\text{in}} \times n_{\text{out}} + n_{\text{out}}. The one twist is the encoder’s last layer: for a code of kk numbers it outputs 2k2k, a mean and a log-variance for each.

The glyph model of Figure 1, with a 2-number code:

import torch.nn as nn

count = lambda m: sum(p.numel() for p in m.parameters())
# the encoder outputs 4 numbers: μ and log-variance for each of 2 codes
encoder = nn.Sequential(nn.Linear(25, 16), nn.Tanh(), nn.Linear(16, 4))
decoder = nn.Sequential(nn.Linear(2, 16), nn.Tanh(),
                        nn.Linear(16, 25), nn.Sigmoid())
print("encoder", count(encoder), "decoder", count(decoder))
print("total", count(encoder) + count(decoder))

It prints:

encoder 484 decoder 473
total 957

The sampling step z=μ+σεz = \mu + \sigma \varepsilon has no parameters. After training only the decoder is needed to generate, so the model you ship is the 473.

What to remember

Try it yourself

Sources