rumblr Work in progressWIP

● The AI Primer · Lesson 34 · Generating images, audio and video

Autoencoders and VAEs

You'll be able to explain Squeezing data into a code and back, and sampling new data from it

Members · open during launch 47 min14 figures and diagrams
Guide is what to use and when. How it works builds it from scratch. Math & code adds the formulas and the Python.

The lesson in one minute

What you'll be able to explain

  1. Autoencoder: an encoder squeezes data through a narrow code (the bottleneck) and a decoder rebuilds it; training minimises the rebuild error. With no bends it learns exactly what PCA learns; with bends it can follow curved data far better.
  2. No generation from a plain autoencoder: its codes land wherever training put them, with holes in between, so random codes decode to junk.
  3. VAE: the encoder outputs a region (μ, σ); the reparameterization trick z = μ + σ·ε samples from it in a way gradients can pass through; a KL penalty pulls every region towards the standard normal, so drawing z ~ N(0, I) and decoding generates new data.
  4. The trade-off: β weighs the KL. Too small brings back holes, too large blurs and finally collapses; squared error averages over overlapping possibilities, which is why VAE output is blurry.
  5. Today: VAEs compress images for latent diffusion, and VQ-VAEs turn images and audio into tokens for transformers.

Level 1

The practitioner's guide

In one sentence

An autoencoder is a pair of networks trained to squeeze data through a narrow code and rebuild it, which makes it a learned lossy compressor; a variational autoencoder (VAE) also shapes the code space so that a random code decodes to something sensible, which makes it a generator and, far more often today, the compressed space that bigger generators (diffusion models, transformers) work inside.

When you need it

Three tells. You have unlabelled data and want a compact, meaningful representation of each item (a fingerprint for search, a small input for another model, a way to flag the items that don't fit): that is a plain autoencoder. You want to generate or edit new items and need a smooth space where nearby codes mean similar outputs: that is a VAE. You are building or running an image, audio or video generator: there is almost certainly an autoencoder at both ends of it, and its settings (downsampling factor, latent channels, scale factor, precision) are yours to get right. You don't need one for compression that must be exact (a zip file is lossless; an autoencoder never is), and you rarely train one for images or audio yourself any more: pretrained ones are downloadable, and their quality took a great deal of data to reach. The number that shows the naive path failing: in this lesson a plain autoencoder rebuilds 8 × 8 pen strokes from two numbers with an error of 0.07 (99% of the picture kept, against 50% for PCA), yet decoding random codes drawn from the standard bell curve gives junk 76% of the time. Compression is not generation.

Your options

From the cheapest to the most capable:

Option What it does What it guarantees What it costs Where it lives
PCA Fits a flat sheet through the data; an item's code is where it lands on the sheet The best any flat code can do under squared error; no training loop One matrix decomposition; poor rebuilds of curved data (50% kept here) A library call
Plain autoencoder A bent encoder and decoder trained only to rebuild the input Far better rebuilds on curved data (99% kept here); a code that flags anomalies and can denoise A training run; a code space with holes, so no generation Your training loop
VAE The encoder emits a fuzzy region per item and pays rent for straying from the standard bell curve Random codes decode sensibly (36% junk here, against 76%); smooth interpolation Blurrier rebuilds; a β to tune; posterior collapse if you overdo it Your training loop, or a pretrained one
Latent autoencoder for diffusion A VAE with a tiny KL weight; the diffusion model generates inside its code space 48 times fewer numbers for a 512 × 512 image, so training and sampling become affordable A ceiling on detail set by the decoder; scaling, precision and memory settings to respect Downloaded with the diffusion model
VQ-VAE and VQGAN Snaps each code vector to the nearest entry of a learned codebook, so an item becomes a grid of token ids Tokens a transformer reads and writes like words; sharper output when an adversarial loss is added (VQGAN) A codebook to keep in use; a discrete space with no straight-line interpolation A pretrained tokenizer
Neural audio codec The same recipe for sound: encoder, residual quantizer, decoder, reconstruction plus adversarial losses Speech and music at 3 to 18 kbit/s, streamable in real time A model at both ends of the wire SoundStream, EnCodec

How to choose

Start from what you want the code for.

  • Compact features for search, clustering or a downstream model, and no generation: try PCA first (Level 2 shows it is exactly the autoencoder with no bends). Train an autoencoder when the data is curved and PCA's rebuilds are poor, as they are here.
  • Flagging oddities (fraud, a failing machine): a plain autoencoder trained on normal data, with an alarm on rebuild error.
  • Generating or editing new items in a small domain, or sliders that mean something: a VAE, with β chosen by looking at samples, not at the loss.
  • Generating images, audio or video at real resolution: don't generate with the VAE. Use it as the compressor and let diffusion or a transformer do the generating, and download the autoencoder that generator was trained with, because the pair is matched.
  • Feeding images or audio into a language-model-style transformer: a VQ tokenizer or a neural codec.
  • Whatever you pick, look at the rebuilds before anything else. The decoder's rebuild quality is the ceiling of every generator that works in its space; no generator can produce detail the decoder cannot paint.

What it costs

Training cost is dominated by data: the autoencoder must see enough of the domain to rebuild it. At run time it is cheap: one encoder pass on the way in and one decoder pass on the way out, against the many passes of the generator between them. That is the economics of latent diffusion. Rombach et al. tried downsampling factors from 1 (raw pixels) to 32 and found factors 4 and 8 the sweet spot; after two million training steps the pixel-space model trailed the factor-8 model by 38 FID points, and on inpainting the latent models ran at least 2.7 times faster. Memory: at high resolution the decoder's activations fill the GPU, which is why the diffusers AutoencoderKL offers tiled encoding and decoding (constant memory, at the risk of faint tile seams) and runs the SDXL autoencoder in float32 by default. Quality has a measured ceiling: the Stable Diffusion 3 paper reports that widening the latent from 4 to 8 to 16 channels drops reconstruction FID from 2.41 to 1.56 to 1.06 and raises PSNR from 25.12 to 26.40 to 28.62, which is why newer models carry 16-channel latents at the price of a harder generation task. β is a dial with a bottom: in this lesson, samples land nearest real strokes at β = 0.3 (median distance 0.56) and further at both β = 0.01 (2.40) and β = 3 (4.73). Tokens cost context: DALL-E's discrete VAE turns a 256 × 256 image into 32 × 32 = 1,024 tokens from a codebook of 8,192, cutting the transformer's context 192-fold.

What breaks

  • Holes. A plain autoencoder's codes land wherever training put them (from −24 to 14 here), with empty fields between; 44% of random codes drawn even from inside that range decode to junk. If you need to sample, you need the KL rent: use a VAE.
  • Blur. Under squared error the best guess for an uncertain pixel is the average, so a VAE whose regions overlap paints averages. Lower β for sharper rebuilds, or add an adversarial loss as VQGAN does, or stop asking the VAE to generate and let a stronger model do it in its space.
  • Posterior collapse. Raise β too far and every region becomes the standard bell curve, the code carries nothing, and the decoder emits the same average picture for every code (β = 3 here: KL exactly 0, rebuild error 6.08, one grey smudge). Watch the KL term; zero is a symptom.
  • Forgetting the latent scale. Diffusion libraries multiply latents by a scaling factor (0.18215 for Stable Diffusion's autoencoder) so they have unit variance for the generator, and divide it back out before decoding. Skip either step and the generator sees data it never trained on.
  • Precision. The Stable Diffusion autoencoders overflow in float16 at high resolution; run them in float32 or use a checkpoint fine-tuned for half precision.
  • Dead codebook entries. In a VQ model, entries nothing maps to waste the vocabulary. DALL-E raised its KL weight to 6.6 to promote codebook usage; if your tokens cluster on a few ids, that is the dial.
  • A mismatched pair. Latents from one autoencoder decoded by another are junk. Keep the encoder, the generator and the decoder that were trained together.

In the wild

Stable Diffusion's KL-regularised autoencoder (8 times downsampling, 4 latent channels) ships with every Stable Diffusion model and is AutoencoderKL in Hugging Face diffusers; Stable Diffusion 3 moved to 16 channels. VQGAN (Esser, Rombach and Ommer) adds an adversarial loss to a VQ-VAE and puts a transformer over its tokens; the original VQ-VAE compressed 128 × 128 images to a 32 × 32 grid over a codebook of 512, about 42.6 times fewer bits, and generated with a PixelCNN over the grid; DALL-E's discrete VAE with 8,192 codes fed its text-to-image transformer. For sound, SoundStream (a convolutional encoder and decoder around a residual vector quantizer, 3 to 18 kbit/s, and at 3 kbit/s preferred over Opus at 12) and EnCodec (a streaming encoder-decoder with a multiscale spectrogram adversary, at 24 kHz mono and 48 kHz stereo) are the same recipe at audio's scale. Every paper is linked at the end of the lesson.

Go deeper

Level 2 builds both halves by hand on 8 × 8 pen strokes, shows where a plain autoencoder's holes come from, then adds the VAE's two pieces (the reparameterization trick and the KL rent) with numbers you can check, and sweeps β to watch holes give way to blur and then collapse. If you only needed to choose, you are done.

Level 2

How it works, from scratch

You phone a friend and describe a picture so they can draw it, but you are allowed to say only two numbers. You would agree on a system first: the first number says which way the line runs, the second says how far it sits from the middle. You squeeze the picture into two numbers, send them, and your friend rebuilds it.

An autoencoder is a neural network that invents that system by itself. It has two halves. The encoder squeezes a picture into a few numbers, called the code. The decoder rebuilds the picture from the code. Nobody tells it what the numbers should mean. The only instruction is "the rebuild must match the original", and to obey it through such a narrow gap the network has to discover what really varies in the data.

Then comes a second question. If your friend can draw from any two numbers, can you invent a new picture by making up two numbers? With a plain autoencoder, usually not: most made-up codes were never used for anything, and the drawing comes out as a smudge. A variational autoencoder (VAE) is trained so that a made-up code drawn from a known range draws something sensible. That step, from compressing to generating, is what this lesson is about, and it opens this part of the primer: GANs (primer.ml.generative.gans) and diffusion (primer.ml.generative.diffusion) are other answers to the same question.

Chapter 1

The toy data: pictures of pen strokes

Tiny example Every picture in this lesson is 8 × 8 = 64 pixels holding one soft pen stroke. The stroke runs in one of four directions (horizontal, vertical, diagonal down, diagonal up) and is shifted up to 2.5 pixels from the centre. The ink fades like a bell curve either side of the line: a pixel on the line has brightness 1.0, a pixel one pixel away about 0.25, a pixel two away almost nothing.

So each picture is 64 numbers, but only two facts change from picture to picture: the direction (one of four) and the offset (any amount). A good 2-number code has to rediscover both from the pixels alone. The 200 pictures are the top row of the first figure below.

In code: make_strokes draws the pictures and returns their hidden directions and offsets; STROKE_KINDS names the four directions.

Chapter 2

Squeeze and rebuild: the autoencoder

Everyday picture A zip file squeezes a document and gets it back exactly. An autoencoder is a lossy, learned zip: it keeps what matters most for this kind of data and lets the rest go.

Tiny example: a one-number code you can check by hand. Take points that lie on the line y = 2x, such as (1, 2) and (2, 4). Each takes two numbers to write down, but one number is enough: how far along the line the point is.

  • Encoder: code = u · x, where u = (1, 2)/√5 = (0.447, 0.894) is the line's direction scaled to length 1, and "·" is the dot product (multiply matching entries and add; see primer.notation).
  • Decoder: rebuild = code × u.

For (1, 2): code = 0.447 + 1.789 = 2.236 (which is √5, its distance from the origin), and rebuild = 2.236 × (0.447, 0.894) = (1, 2). Nothing lost.

For (2, 3), which is off the line: code = (2 + 6)/√5 = 3.578, and rebuild = 3.578 × (0.447, 0.894) = (1.6, 3.2), the closest point on the line. The part that pointed away from the line, (0.4, −0.2), is gone. That loss is exactly what training measures and shrinks:

Level 3: the formula and its symbols

Symbols

Symbol Meaning here In the example
the -th example: a point, or a picture's 64 pixels
the encoder: squeezes an example into a code
the code for example , often written 3.578
the decoder: rebuilds an example from a code
the rebuild, often written ("x hat") (1.6, 3.2)
what the rebuild got wrong, entry by entry (0.4, −0.2)
squared length of : square every entry and add
add up over every example
how many examples 2: (1, 2) and (2, 3)
the reconstruction loss: average squared error of the rebuilds 0.1

In words: "squeeze each example, rebuild it, measure the squared distance between the rebuild and the original, and average over all the examples."

With the numbers: (1, 2) rebuilds perfectly, error 0. (2, 3) rebuilds as (1.6, 3.2), error 0.4² + (−0.2)² = 0.16 + 0.04 = 0.2. Averaged over the two points, = (0 + 0.2)/2 = 0.1.

Level 3: in Python
import math
# u: the line's direction, scaled to length 1
u = [1 / math.sqrt(5), 2 / math.sqrt(5)]
def f(x):
    return sum(u_m * x_m for u_m, x_m in zip(u, x))
def g(z):
    return [z * u_m for u_m in u]
def error(x):
    return sum((a - b) ** 2 for a, b in zip(x, g(f(x))))
# f(x): the encoder squeezes (2, 3) to one number
round(f([2, 3]), 3)  # → 3.578
# g(f(x)): the decoder rebuilds two numbers from it
[round(v, 2) for v in g(f([2, 3]))]  # → [1.6, 3.2]
# ‖x − x̂‖² for each point: (1, 2) is on the line, (2, 3) is not
[round(error(x), 2) for x in ([1, 2], [2, 3])]  # → [0.0, 0.2]
# L_rec: the average over n = 2 points
round((error([1, 2]) + error([2, 3])) / 2, 2)  # → 0.1

The real network has the same two halves, only bent. The encoder is a two-layer network like the one built in primer.ml.neural_net: 64 pixels into 32 hidden numbers (through tanh, which lets it bend), then out to a code of 2 numbers. The decoder mirrors it: 2 numbers into 32 hidden, then out to 64 pixels squashed between 0 and 1 by a sigmoid so they are valid brightnesses. Training is the loop from primer.ml.neural_net with the Adam optimizer from primer.ml.optimizers: rebuild every picture, measure , send the gradient back through both halves, adjust, 1,500 times.

Figure 3 · Diagram

Reading it: follow the picture left to right. It is 64 numbers wide at both ends and only 2 numbers wide in the middle: that narrow middle is the bottleneck, and it is the whole point. Without it the network could copy the pixels straight through and learn nothing. The loss box compares the two ends, and the dotted arrow is backpropagation carrying the blame back through the decoder and on into the encoder, so both halves learn together. The encoder never sees a target code; it learns whatever code the decoder finds most useful.

Figure 1 · Drawn from the lesson's code

the original pictures (64 pixels each) autoencoder rebuild from 2 numbers (error 0.07) PCA rebuild from 2 numbers (error 4.42)

Eight strokes, their autoencoder rebuilds from 2 numbers (error 0.07) and their PCA rebuilds from 2 numbers (error 4.42): the autoencoder's are near perfect, PCA's are grey smudges

Reading it: the top row is eight real strokes, two of each direction. The middle row is what the autoencoder rebuilds from just 2 numbers per picture: nearly identical. The average error is about 0.07, against about 8.8 units of squared ink in a whole stroke, so it keeps over 99% of the picture. The bottom row squeezes the same pictures to 2 numbers with PCA and rebuilds them: grey smudges, keeping only about half. Same budget of two numbers, very different results. The next section explains why.

In code: worked_example_line is the hand example above; Autoencoder holds both halves and its hand-written backward pass in Autoencoder.loss_and_gradients; train runs Adam; trained_autoencoder is the lesson's trained network; reconstruction_error measures the rebuilds.

PCA is the straight-line special case

Everyday picture PCA (principal component analysis, built in primer.ml.embeddings.clustering) fits a flat sheet through the data and records where each point lands on the sheet. That is an autoencoder with no bends: the encoder is one matrix multiply, and so is the decoder.

Tiny example Scatter 100 points near the line y = 2x and train exactly that no-bend autoencoder, with a 1-number code, by plain gradient descent. The direction it learns is (0.447, 0.894), the line's own direction, and its rebuild error matches PCA's with one component. Baldi and Hornik proved in 1989 that this always happens: a linear autoencoder trained on squared error lands on the same flat sheet as PCA.

So why did PCA smudge the strokes? Because a stroke sliding across the image does not travel in a straight line through pixel space. Take a horizontal stroke at the top and another at the bottom. Their average, the point halfway along the straight line between them, is two faint lines, not one stroke in the middle. The real strokes lie on a curved surface in the 64-dimensional space of pictures, and a flat sheet can only cut through it. The autoencoder's tanh layers let its "sheet" bend to follow the curve.

Why it matters in practice. Before generation, autoencoders earned their keep as learned compressors. They compress, they denoise (train on noisy inputs, ask for clean outputs), and they detect anomalies: an input the network rebuilds badly is unlike anything it trained on, which is a standard way to flag fraud or a failing machine.

Figure 2 · Drawn from the lesson's code

−25 −20 −15 −10 −5 0 code number 1 −15 −10 −5 0 5 10 15 code number 2 A plain autoencoder's codes: one strand per stroke direction where a standard normal draw usually lands horizontal vertical diagonal down diagonal up

The plain autoencoder's 200 codes form separate strands, one colour per stroke direction, spread from about -24 to 14, far from the small circle where a standard normal draw usually lands

Reading it: each dot is one picture, placed at its 2-number code and coloured by its stroke direction, with bigger dots for larger offsets. The codes form strands: pictures of one direction line up along a curve (now and then broken into pieces), and sliding along a strand slides the stroke. The network rediscovered both hidden facts without being told either. Now look at the axes: the codes run from about −24 to 14. Nothing asked for that range; it is an accident of training. The dashed circle near 0 is where a "random" code from the bell curve would usually land, and it catches almost none of the strands. Hold on to that for the next section.

In code: train_linear_autoencoder is the no-bend autoencoder; pca_reconstruction rebuilds from the top principal components.

Chapter 3

Why a plain autoencoder cannot generate: holes

Everyday picture Imagine a town where houses were built only along four winding roads. Pick a random spot inside the town limits and you will most likely land in a field. Ask the decoder to draw the picture that "lives" in that field, and it answers anyway, with whatever its weights happen to produce, because nothing in training ever asked it about that spot.

Tiny example The obvious way to make up a random code is to draw each number from the standard normal distribution, the bell curve centred on 0 with a spread (standard deviation) of 1: most draws fall between −1 and 1, almost all between −3 and 3. Written for several numbers at once, it means "draw each number from that bell curve, independently".

Draw the code z = (1.2, −0.9) and decode it with the plain autoencoder. The result is a bright smear with about three times the ink of any real stroke; its squared distance to the nearest real stroke is about 7.5. Do that 500 times and the median distance is about 3, while three quarters of the draws land further than 1.0 from every real stroke. That 1.0 is this lesson's line for junk: about a ninth of a whole stroke's squared ink.

To be fair to the autoencoder, draw instead from the box that holds its own codes, from −24 to 1.6 across and −17 to 14 up. Still about 44% of those codes decode to junk. They fall in the holes between the strands.

Figure 4 · Drawn from the lesson's code

−20 −10 0 code number 1 −15 −10 −5 0 5 10 15 code number 2 44% of random codes in the box land in a hole codes of real strokes random code, decodes to a stroke random code, decodes to junk decoded: the 12 worst (top) and 12 best (bottom)

Left: the autoencoder's codes and 300 random codes from their bounding box, 44 percent marked as junk; right: the worst decoded ones are black blobs and the best are clean strokes

Reading it: on the left, black dots are the codes of real strokes and the dashed rectangle is the box around them. Every blue circle is a random code that decoded to something close to a real stroke; every red cross decoded to junk. The red crosses sit in the open spaces between strands, the blue circles near them. On the right are the decoded pictures themselves: the 12 worst (top two rows) are black blobs no pen would draw, and the 12 best (bottom) are clean strokes, because those random codes happened to land on a strand.

Why it matters in practice. A compressor is not a generator. To sample new data you need a code space with a known shape that you can draw from, no holes inside that shape, and smoothness, so nearby codes decode to similar pictures. A plain autoencoder promises none of these, because its loss only ever looks at codes of real pictures.

In code: generate decodes codes drawn from the standard normal; codes_in_box draws from a model's own code range; distance_to_data measures how far each image is from the nearest real stroke, and JUNK_DISTANCE is the line between plausible and junk.

Chapter 4

The VAE: encode to a fuzzy region, not a point

Everyday picture Instead of pinning each picture to one exact spot on the map, the encoder draws a small fuzzy circle: "somewhere around here". During training the decoder is handed a random point from inside that circle, so it must draw the right picture from anywhere nearby. A whole neighbourhood now decodes sensibly, not just one pin. A second rule stops the encoder from cheating: every circle pays rent, more the further it sits from the middle of the map and more if it shrinks towards a pin. Circles crowd towards the centre and overlap, and the fields between the roads fill in.

Tiny example One picture, one code number. The encoder says μ = 0.5 and σ = 0.5: "about 0.5, give or take 0.5". This step, a bell-curve roll gives ε = 1.2, so the decoder is handed z = 0.5 + 0.5 × 1.2 = 1.1. Next step the roll is ε = −0.4 and the decoder gets z = 0.5 − 0.2 = 0.3. Both must rebuild the same picture.

Figure 7 · Diagram

Reading it: compare it with the plain autoencoder's diagram. The encoder now has two outputs per code number: a centre μ and a size, given as log σ² (the logarithm of the variance, used because it can be any number while σ itself must stay positive). The box z = μ + σ·ε is where the fuzzy region becomes one concrete code: ε is fresh random noise every step. The loss has two parts. The rebuild error, as before, wants each region small and distinct so the decoder knows exactly which picture it came from. The KL rent, fed straight from μ and log σ², wants every region to look like the standard bell curve. Training settles on a compromise between them, and β sets the exchange rate.

In code: VAE is the Autoencoder with a two-headed encoder; VAE.encode_distribution returns μ and log σ², and VAE.encode returns just the centre μ, the best single code for a picture.

The reparameterization trick: moving the dice outside

Everyday picture To pick a random seat in a row, you could close your eyes and point. If someone then moves the row one seat to the left, you have no idea how your pick would have changed. Or you could roll a die for "how many seats from the middle" and count from wherever the middle is. Now if the row moves one seat left, your seat moves exactly one seat left. Same kind of random seat, but you can say how it responds to the row moving.

Training needs exactly that. Backpropagation asks of every step: "if I nudge this number, how does the loss change?" (that rate of change is the gradient, or derivative; see primer.notation). A raw dice roll has no answer, so the gradient would stop at the sampling step and the encoder would never learn. The reparameterization trick rolls the dice first, as an ordinary input ε, and builds the code with arithmetic:

Level 3: the formula and its symbols

Symbols

Symbol Meaning here In the example
the centre of the picture's region, from the encoder 0.5
the natural logarithm of the region's variance, from the encoder; "log of y" is the power you raise to in order to get y
Euler's number, ≈ 2.718
the spread of the region (standard deviation); undoes the log and the square 0.5
"epsilon": random noise, one number per code number 1.2
"is drawn from"
the standard normal: centre 0, spread 1, each number independent (, the identity matrix, says "no links between numbers")
multiply matching entries (element by element)
the code the decoder receives this step 1.1

In words: "roll a standard bell-curve number, stretch it by the region's spread, and shift it to the region's centre."

With the numbers: $\sigma = e^{\frac{1}{2} \times (-1.386)} = e^{-0.693} = 0.5z = 0.5 + 0.5 \times 1.2 = 1.1$. Now the gradients have a route: nudge μ by a little and z moves by the same amount (∂z/∂μ = 1); nudge σ by a little and z moves by ε times as much (∂z/∂σ = ε = 1.2). The symbol ∂ reads "how much this changes when that is nudged".

Level 3: in Python
import math
mu, log_var, eps = 0.5, math.log(0.25), 1.2
round(log_var, 3)  # → -1.386
# σ = e^(½ log σ²)
sigma = math.exp(0.5 * log_var)
round(sigma, 3)  # → 0.5
# z = μ + σ ⊙ ε
z = mu + sigma * eps
round(z, 3)  # → 1.1
# nudge μ, then σ, by a tiny h and watch z: ∂z/∂μ = 1 and ∂z/∂σ = ε
h = 1e-6
round((mu + h + sigma * eps - z) / h, 3)  # → 1.0
round((mu + (sigma + h) * eps - z) / h, 3)  # → 1.2

Figure 8 · Diagram

Reading it: both rows produce the same kind of random code: a draw from a bell curve centred on μ with spread σ. In the top row the randomness sits between the encoder's outputs and the loss, and the dotted arrow of backpropagation has nowhere to go. In the bottom row the randomness comes in from the side as ε, an input like a pixel, and everything from μ and σ to the loss is ordinary arithmetic the chain rule can pass through. The encoder learns because of this one rearrangement.

Why it matters in practice. The same trick lets gradients pass through random choices elsewhere too, for example in the noisy steps of diffusion models (primer.ml.generative.diffusion) and in reinforcement learning with continuous actions. It is a large part of why the VAE paper mattered.

In code: reparameterize is the formula; in VAE.loss_and_gradients the lines under "Through z = μ + σ·ε" are the two gradients above, and a test checks them against finite differences.

The KL penalty: rent for every region

Everyday picture The rent from the everyday picture has a formal name: the KL divergence (Kullback-Leibler divergence) from the region to the standard bell curve, a measure of how different two distributions are that is 0 only when they match (primer.ml.training_stages uses it for distillation). For a bell-curve region and the standard bell curve, it has a short closed form:

Level 3: the formula and its symbols

Symbols

Symbol Meaning here In the example
how much distribution differs from distribution ; 0 when they match
the picture's region: a bell curve with centre μ and variance σ²
the standard bell curve every region is pulled towards
how many numbers in the code 1 (the lesson's network uses 2)
a counter over the code numbers
rent for sitting away from the centre: 0 at μ = 0, growing on both sides 0.25
rent on size: smallest (exactly 1) when σ = 1, huge as σ shrinks to a pin, growing if it bloats
shifts the total so a perfect match costs exactly 0
a constant that falls out of the bell curve's formula

In words: "for each code number, add the squared distance of its centre from 0, plus its variance, minus the log of its variance, minus 1; add those up over the code numbers and halve."

With the numbers: for μ = 0.5 and σ = 0.5, ½ (0.25 + 0.25 − (−1.386) − 1) = ½ × 0.886 = 0.443. It splits into two rents: the centre alone costs ½ × 0.25 = 0.125, the shrunken size alone ½ (0.25 + 1.386 − 1) = 0.318, and 0.125 + 0.318 = 0.443. A second code number that already has μ = 0 and σ = 1 adds ½ (0 + 1 − 0 − 1) = 0, so the 2-number total is still 0.443. Shrink σ to a pin of 0.05 and the rent jumps to 2.62.

Level 3: in Python
import math
def kl(mu, var):
    return 0.5 * (mu ** 2 + var - math.log(var) - 1)
# one code number with μ = 0.5, σ = 0.5 (σ² = 0.25)
round(kl(0.5, 0.25), 3)  # → 0.443
# the rent for the centre alone, then for the size alone
round(0.5 * 0.5 ** 2, 3)  # → 0.125
round(kl(0.0, 0.25), 3)  # → 0.318
# a code number that already is the standard normal pays nothing
kl(0.0, 1.0)  # → 0.0
# Σ over j: two code numbers, their rents add
round(kl(0.5, 0.25) + kl(0.0, 1.0), 3)  # → 0.443
# shrink σ to a pin (σ = 0.05) and the rent jumps
round(kl(0.5, 0.05 ** 2), 2)  # → 2.62

Figure 5 · Drawn from the lesson's code

−3 −2 −1 0 1 2 3 mean μ (spread held at σ = 1) 0 1 2 3 4 KL penalty Rent for sitting far from the centre μ = 0.5 costs 0.125 0.0 0.5 1.0 1.5 2.0 2.5 3.0 spread σ (mean held at μ = 0) 0.0 0.5 1.0 1.5 2.0 2.5 3.0 3.5 Rent for shrinking to a pin (or bloating) σ = 0.5 costs 0.318

Left: the KL penalty is a parabola in the centre mu, with mu = 0.5 costing 0.125; right: in the spread sigma it is zero at sigma = 1, rises steeply towards a pin, with sigma = 0.5 costing 0.318

Reading it: the left curve holds the spread at σ = 1 and moves the centre: a bowl with its bottom at μ = 0, so sitting at 0.5 costs 0.125 and sitting at 3 costs 4.5. The right curve holds the centre at 0 and changes the spread. It is 0 at σ = 1 (the dashed line), rises gently if the region bloats, and shoots up as σ heads towards 0. That steep wall on the left is what stops the encoder from shrinking every region to a pin and turning back into a plain autoencoder with holes. The two red dots add up to the worked example's 0.443.

In code: kl_to_standard_normal is the formula, summed over the code numbers; a test checks it against a brute-force average over 400,000 samples.

The whole VAE loss

Level 3: the formula and its symbols

Symbols

Symbol Meaning here In the example
the picture (2, 3) from the line example
the decoder's rebuild from the sampled code (1.6, 3.2)
the rebuild error, as in the plain autoencoder 0.2
the region the encoder draws for picture : read "q of z given x"
"beta": how much one unit of KL rent costs, in units of rebuild error 0.3
KL(…) the rent from the previous section 0.443

In words: "rebuild well, but pay β for every unit by which your regions differ from the standard bell curve."

With the numbers: 0.2 + 0.3 × 0.443 = 0.2 + 0.133 = 0.333.

Level 3: in Python
rebuild, kl_value, beta = 0.2, 0.443, 0.3
# L = ‖x − x̂‖² + β · KL
round(rebuild + beta * kl_value, 3)  # → 0.333

Where does this come from? The VAE paper derives the loss (with β = 1) as the negative of the evidence lower bound (ELBO): a quantity that is guaranteed never to exceed the log-probability the model gives to the real pictures, so pushing the bound up pushes that probability up. Choosing squared error amounts to assuming each pixel is the decoder's output plus bell-curve noise of spread s, and working that through gives β = 2s². This lesson uses β = 0.3, which assumes pixel noise of spread about 0.39.

Figure 6 · Drawn from the lesson's code

−3 −2 −1 0 1 2 3 code number 1 (μ) −3 −2 −1 0 1 2 3 code number 2 (μ) A VAE's codes: fuzzy regions packed inside the bell curve horizontal vertical diagonal down diagonal up

The VAE's 200 codes packed within about two units of the origin, one strand per stroke direction, each with a small shaded fuzzy region

Reading it: the same 200 pictures as the plain autoencoder's map, now placed at their centres μ, each with its fuzzy region shaded around it (regions are drawn one σ wide). The dashed circles are radius 1 and 2 of the standard bell curve. Compare the axes with the plain autoencoder's: the codes now sit around 0 with a spread of about 1.1 in each direction, instead of wandering from −24 to 14. The strands are still there (the code still knows direction and offset), but they are packed side by side and the regions (σ about 0.14) overlap along each strand, so there is far less empty field left.

In code: VAE.loss_and_gradients computes both terms and their gradients; trained_vae trains the lesson's VAE at a chosen β.

Chapter 5

Sampling new strokes

Everyday picture Once the map is packed, throw a dart at the middle of it and you land in a neighbourhood, not a field. Generating is exactly that: throw away the encoder, draw a code from the standard bell curve, and decode.

Tiny example Draw the same code as before, z = (1.2, −0.9), and decode it with the VAE. Out comes a horizontal stroke through the centre of the picture, with squared distance about 0.06 from a real stroke in the training set. The plain autoencoder turned that same code into a smear 7.5 away. Over 500 draws the VAE's median distance is about 0.56 against the plain autoencoder's 3.

Figure 12 · Diagram

Reading it: generating uses only the right half of the network. The encoder's job was done during training: it taught the decoder, through the KL rent, that codes live where the standard normal puts them. That is why the first box can draw from with confidence. The dotted arrow is a reminder that nothing flows from the encoder here.

Figure 9 · Drawn from the lesson's code

plain autoencoder: 24 codes from N(0, I) (median distance to a real stroke 2.93) VAE: 24 codes from N(0, I) (median distance to a real stroke 0.56)

Left: 24 codes from N(0, I) decoded by the plain autoencoder, many black blobs; right: the same codes decoded by the VAE, clean strokes and a few soft blends

Reading it: both panels decode the same 24 random codes. The plain autoencoder (left) gives blobs, broken lines and a few strokes by luck. The VAE (right) gives strokes in all four directions at many offsets, plus a few soft blends of two directions. Those blends are the VAE's honest weak spot: with only 2 numbers to hold four directions, some codes sit on the border between two strands, and the decoder hedges between them. About a third of the VAE's samples still cross the junk line, most of them blends like these.

Figure 10 · Drawn from the lesson's code

code number 1 → code number 2 → Decoding every point of a grid from -2.2 to 2.2

An 11 by 11 grid of codes from -2.2 to 2.2, each decoded by the VAE: neighbouring tiles are similar strokes, and regions of the map hold each direction

Reading it: every tile is the VAE's decoding of one point on an even grid over the middle of the code map. Read along any row or column: the stroke changes a little at a time, sliding or turning, with no sudden jumps to junk. Whole regions of the map belong to one direction, and the borders between them are where the blends live. This is what a latent space means: a space of codes where position means something. That smoothness is what lets you edit or explore a picture by moving its code.

Interpolation: walking between two codes

Everyday picture A morph between two faces in a film: every in-between frame should look like a face, not a double exposure.

Tiny example Encode two pictures to codes and , step along the straight line between them, and decode every stop:

Level 3: the formula and its symbols

Symbols

Symbol Meaning here In the example
the codes of the two pictures at the ends of the walk and
how far along the walk: 0 at , 1 at 0.25
the code at that point of the walk

In words: "take a share of the first code and a share of the second, and add them."

With the numbers: at , $z_t = 0.75 \times (-1, 0.5) + 0.25 \times (1, 1.5) = (-0.75 + 0.25,\ 0.375 + 0.375) = (-0.5, 0.75)$. The walk in the figure uses nine stops, .

Level 3: in Python
z_a, z_b = [-1.0, 0.5], [1.0, 1.5]
# z_t = (1 − t) z_a + t z_b, a quarter of the way along
t = 0.25
[(1 - t) * a + t * b for a, b in zip(z_a, z_b)]  # → [-0.5, 0.75]
# the nine stops of a walk
[i / 8 for i in range(9)]  # → [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]

Figure 11 · Drawn from the lesson's code

plain autoencoder: the worst stop is 3.88 from any real stroke VAE: the worst stop is 0.70 from any real stroke Walking in a straight line between two vertical strokes (offsets -1.15 and 1.95)

Two walks between the same two vertical strokes: the plain autoencoder's passes through a smeared cross 3.88 from any real stroke; the VAE's passes through diagonal strokes, never more than 0.70 from a real one

Reading it: both rows walk between the same two vertical strokes, one left of centre and one right of it. The plain autoencoder's walk (top) leaves its strand and crosses a hole: the middle frames are a smeared cross, 3.88 away from any real stroke. The VAE's walk (bottom) takes a surprising route, through diagonal strokes, because in its 2-number map the diagonal strand sits between those two points. But every stop is a believable stroke, never more than 0.70 from a real one. Averaged over 60 random pairs, the VAE's biggest jump from one frame to the next is also smaller (about 1.6 against 2.4).

Why it matters in practice. Smooth latent spaces are what made "move this slider to add a smile" demos possible, and interpolation is still a standard sanity check for any learned code: if the walk is full of junk, the space has holes.

In code: generate draws and decodes; interpolate encodes two pictures and decodes the walk between them; largest_step measures its biggest jump.

Chapter 6

The trade-off: β, blur and collapse

Everyday picture Go back to the rent. Set it too low and every region shrinks to a pin far from the centre: rebuilds are sharp, but the holes come back. Set it too high and every region moves to the centre and swells to the full bell curve. Then every picture's region is the same region, the code carries no information at all, and the decoder can do nothing better than draw the average of all the strokes: a grey smudge. That failure is called posterior collapse.

Tiny example The lesson trains six VAEs, with β from 0.01 to 3:

β rebuild error KL (information in the code) median sample distance to a real stroke
0.01 0.16 10.3 2.40
0.1 0.23 5.7 0.87
0.3 0.31 4.4 0.56
1 0.92 2.9 0.81
3 6.08 0.0 4.73

(beta_sweep produces this table, including β = 0.03, and demo prints it.)

The samples are best in the middle. At β = 3 the KL is zero: the regions are the standard bell curve itself, and the rebuild error of 6.08 is what you get by drawing the average stroke every time.

Why VAE pictures are blurry

Even at a good β the regions overlap, so one code can stand for several different pictures. What should the decoder draw for it? Take a single pixel that is black (1) in one of those pictures and white (0) in the other, equally likely. Squared error has a clear answer:

Level 3: the formula and its symbols

Symbols

Symbol Meaning here In the example
the pixel's true value, which could be either picture's 0 or 1, equally likely
the decoder's guess for that pixel 0, 0.5 or 1
the squared error of the guess
expected value: the average over the possibilities, each weighted by its chance
"the that makes what follows as small as possible"

In words: "the guess with the smallest average squared error is the average of the possibilities."

With the numbers: guess black (1): error 1 half the time, 0 otherwise, average 0.5. Guess white (0): also 0.5. Guess grey (0.5): error 0.25 either way, average 0.25. Grey wins, and grey is the average of 0 and 1.

Level 3: in Python
outcomes = [0.0, 1.0]
def expected_error(c):
    return sum((x - c) ** 2 for x in outcomes) / len(outcomes)
# E[(x − c)²] for three guesses: white, grey, black
[expected_error(c) for c in (0.0, 0.5, 1.0)]  # → [0.5, 0.25, 0.5]
# E[x]: the average outcome, which is the winning guess
sum(outcomes) / len(outcomes)  # → 0.5

Across a whole picture, "average the pictures this code might mean" is a blur. The more the regions overlap, the more pictures each code must stand for, and the blurrier the output.

Figure 13 · Drawn from the lesson's code

1 0 − 2 1 0 − 1 1 0 0 β (weight on the KL penalty) 0 2 4 6 8 10 Too little β: holes. Too much: blur, then collapse rebuild error KL (information in the code) samples' distance to a real stroke β = 0.01 β = 0.03 β = 0.1 β = 0.3 β = 1 β = 3 8 samples at each β (rows, top to bottom)

Left: as beta grows from 0.01 to 3, rebuild error rises, KL falls to zero, and sample distance is lowest near beta 0.3; right: 8 samples per beta, sharp but broken at 0.01, clean at 0.3, softer at 1 and uniform grey smudges at 3

Reading it: on the left, β grows along a logarithmic axis. The blue rebuild error stays low, then climbs steeply past β = 1. The green KL, the information the code carries, falls steadily and hits zero at β = 3. The red line, how far random samples are from real strokes, is a U: high at small β (holes), lowest near β = 0.3, high again at large β (blur and collapse). On the right are eight samples at each β. The top rows have sharp ink in junk shapes. The middle rows are clean strokes. The β = 1 row is softer. The bottom row is eight copies of the same grey smudge: the collapse.

Why it matters in practice. β is a real dial, and the name β-VAE (Higgins and colleagues, 2017) comes from turning it up to get codes whose numbers line up with separate factors, like direction and offset here. The blur is the VAE's best-known weakness. Modern systems fix it by adding a second judge of realism to the loss (an adversarial loss, the trick behind primer.ml.generative.gans, as in VQGAN), or by letting a stronger generator do the generating while the autoencoder only compresses.

In code: beta_sweep trains and measures one VAE per β; expected_squared_error is the grey-pixel calculation.

Chapter 7

Where autoencoders live today

Everyday picture The autoencoder became the zip format for images and sound that other generative models work inside.

Tiny example Stable Diffusion's autoencoder turns a 512 × 512 colour image (512 × 512 × 3 = 786,432 numbers) into a 64 × 64 × 4 grid of codes (16,384 numbers): 48 times fewer. The diffusion model does all its work on that small grid, and the decoder paints full-size pixels only at the very end. Its KL weight is tiny, so it is mostly a compressor, with just enough rent to keep the codes well-behaved.

A VQ-VAE (vector-quantized VAE) goes one step further: it snaps each code vector to the nearest entry of a learned codebook, so an image becomes a grid of whole numbers, the entries' positions in the codebook. Those numbers are tokens, and a transformer can read and write them exactly as it reads and writes words. Neural audio codecs do the same for sound.

Figure 14 · Diagram

Reading it: in the top row the autoencoder is the outer shell: its encoder shrinks the image 48-fold on the way in, its decoder restores it on the way out, and the expensive generator in the middle never touches a pixel. Training and sampling run far faster on 16,384 numbers than on 786,432, which is what made high-resolution diffusion affordable (see primer.ml.generative.diffusion). In the bottom row the snapping step turns continuous codes into token ids, which is how images and audio can enter and leave a language-model-style transformer (see primer.ml.generative.multimodal).

Why it matters in practice. When a model "generates an image", there is very often an autoencoder at both ends of the pipeline. Its quality sets a ceiling: whatever detail the decoder cannot rebuild, no generator working in its latent space can produce.

Test yourself

8 questions

Answer each one out loud or on paper before you open it. If you can explain it, you know it.

Question 1What is the bottleneck for? What goes wrong if the code is as wide as the input?Think it through, then reveal

It forces the network to keep only what matters: with 2 numbers for 64 pixels, it must find the few facts that actually vary. If the code is as wide as the input, the network can learn to copy the pixels straight through, rebuild perfectly and learn nothing useful, unless something else (noise on the input, a penalty on the code) stops it.

Question 2Why can't you generate new pictures by decoding random codes from a plain autoencoder?Think it through, then reveal

Its loss only ever sees the codes of real pictures, so it says nothing about where codes should live or what lies between them. The codes end up in an arbitrary range with holes between clusters, and a random code usually lands in a hole, where the decoder's output is junk.

Question 3What problem does the reparameterization trick solve?Think it through, then reveal

Backpropagation needs every step between the weights and the loss to be something you can differentiate, and a random draw is not. Writing the draw as z = μ + σ·ε with the noise ε as a separate input turns sampling into arithmetic, so gradients reach μ and σ, and through them the encoder.

Question 4What do the two terms of the VAE loss each want, and why do you need both?Think it through, then reveal

The rebuild term wants regions small and far apart so every picture is decoded precisely. The KL term wants every region to be the standard bell curve. Without KL you get a plain autoencoder with holes; without the rebuild term every region collapses onto the bell curve and the code carries nothing. The balance gives a packed, smooth code space that still tells pictures apart.

Question 5Work out the KL penalty for one code number with μ = 0 and σ = 2.Think it through, then reveal

½ (0 + 4 − log 4 − 1) = ½ (3 − 1.386) = 0.807. A region that is too big pays rent too, though less steeply than one that is too small.

Question 6Why are VAE samples blurry?Think it through, then reveal

Regions overlap, so one code can stand for several pictures, and under squared error the best single answer is their average. Averaged pictures are blurred pictures. Raising β increases the overlap and the blur.

Question 7What is posterior collapse?Think it through, then reveal

When the KL rent outweighs what the code saves in rebuild error, the encoder makes every region the standard bell curve, the code carries no information, and the decoder outputs the same average picture for every code. In this lesson that happens at β = 3.

Question 8Why does latent diffusion run inside an autoencoder's code space instead of on pixels?Think it through, then reveal

The code is many times smaller (48 times for Stable Diffusion's 512 × 512 images) and keeps what matters to the eye, so the expensive, many-step generator is far cheaper to train and run. The decoder turns the result back into full-size pixels once, at the end.

Primary sources

The papers behind this lesson

Hinton & Salakhutdinov, Reducing the Dimensionality of Data with Neural Networks (Science, 2006)

Showed that deep autoencoders, once they could be trained, compress data far better than PCA.

The paper ↗
Kingma & Welling, Auto-Encoding Variational Bayes (2013)

Introduced the variational autoencoder, the reparameterization trick and the ELBO loss with its closed-form Gaussian KL.

Read the annotated companion →The paper ↗
Rezende, Mohamed & Wierstra, Stochastic Backpropagation and Approximate Inference in Deep Generative Models (2014)

Developed the same idea independently at the same time, showing how to backpropagate through random sampling.

The paper ↗
Burgess et al., Understanding disentangling in β-VAE (2018)

Explains why turning up β pushes the code's numbers to line up with separate factors of the data, and what it costs in rebuild quality.

The paper ↗
van den Oord, Vinyals & Kavukcuoglu, Neural Discrete Representation Learning (2017)

Introduced the VQ-VAE, which snaps codes to a learned codebook and so turns images and audio into tokens.

Read the annotated companion →The paper ↗
Rombach et al., High-Resolution Image Synthesis with Latent Diffusion Models (2021)

Ran diffusion inside a lightly regularized autoencoder's code space, the design behind Stable Diffusion.

The paper ↗

Researcher's shelf

Further reading

  • Kingma & Welling, An Introduction to Variational Autoencoders (2019): https://arxiv.org/abs/1906.02691
  • Carl Doersch, Tutorial on Variational Autoencoders (2016): https://arxiv.org/abs/1606.05908
  • Goodfellow, Bengio & Courville, Deep Learning, chapter 14, Autoencoders: https://www.deeplearningbook.org/contents/autoencoders.html
  • Esser, Rombach & Ommer, Taming Transformers for High-Resolution Image Synthesis (VQGAN, 2020): https://arxiv.org/abs/2012.09841
  • PyTorch's VAE example: https://github.com/pytorch/examples/tree/main/vae

About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit 048aeaa, so the two always agree: the explanation, the code that builds it and the tests that prove it.