At a glance
Key takeaways
- 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.
- No generation from a plain autoencoder: its codes land wherever training put them, with holes in between, so random codes decode to junk.
- 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.
- 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.
- Today: VAEs compress images for latent diffusion, and VQ-VAEs turn images and audio into tokens for transformers.
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 1 · Diagram
flowchart LR X["picture x<br/>64 pixels"] --> E["encoder f<br/>64 → 32 → 2"] E --> Z["code z<br/>2 numbers"] Z --> D["decoder g<br/>2 → 32 → 64"] D --> XH["rebuild x̂<br/>64 pixels"] X --> L["loss<br/>squared error between x and x̂"] XH --> L L -. "gradients flow back<br/>through decoder, then encoder" .-> E
Figure 2 · Drawn from the lesson's code
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
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 3 · Drawn from the lesson's code
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
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
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
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 5 · Diagram
flowchart LR X["picture x"] --> E["encoder"] E --> MU["μ: centre of the region"] E --> LV["log σ²: size of the region"] EPS["ε drawn from N(0, I)"] --> Z["z = μ + σ·ε"] MU --> Z LV --> Z Z --> D["decoder"] --> XH["rebuild x̂"] XH --> R["rebuild error<br/>‖x − x̂‖²"] X --> R MU --> KL["KL rent<br/>pulls regions to N(0, I)"] LV --> KL R --> LOSS["loss = rebuild + β · KL"] KL --> LOSS
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 6 · Diagram
flowchart LR
subgraph A["Sampling directly: the gradient stops"]
direction LR
m1["μ, σ"] --> s1["draw z from N(μ, σ²)<br/>a dice roll"] --> d1["decoder"] --> l1["loss"]
l1 -. "no route back<br/>through a dice roll" .-> s1
end
subgraph B["Reparameterized: the gradient flows"]
direction LR
e2["ε from N(0, I)<br/>just another input"] --> s2["z = μ + σ·ε<br/>plain arithmetic"]
m2["μ, σ"] --> s2 --> d2["decoder"] --> l2["loss"]
l2 -. "∂z/∂μ = 1, ∂z/∂σ = ε" .-> m2
end
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 7 · Drawn from the lesson's code
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
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 8 · Drawn from the lesson's code
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
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 9 · Diagram
flowchart LR N["draw z from N(0, I)<br/>2 random numbers"] --> D["decoder g"] --> X["a new picture<br/>never seen in training"] ENC["encoder"] -. "not used when generating" .-> N
Figure 10 · Drawn from the lesson's code
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
Figure 11 · Drawn from the lesson's code
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
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 12 · Drawn from the lesson's code
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
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
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
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
flowchart LR
subgraph LD["Latent diffusion"]
direction LR
I1["image<br/>512 × 512 × 3"] --> E1["VAE encoder"] --> L1["latent<br/>64 × 64 × 4"]
L1 --> DF["diffusion model<br/>works here"] --> D1["VAE decoder"] --> O1["image"]
end
subgraph TK["Tokenizer for a transformer"]
direction LR
I2["image or audio"] --> E2["encoder"] --> Q["snap each vector to<br/>nearest codebook entry"]
Q --> T["grid of token ids"] --> TR["transformer"]
TR --> D2["decoder"] --> O2["image or audio"]
end
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
Showed that deep autoencoders, once they could be trained, compress data far better than PCA.
The paper ↗Introduced the variational autoencoder, the reparameterization trick and the ELBO loss with its closed-form Gaussian KL.
Read the annotated companion →The paper ↗Developed the same idea independently at the same time, showing how to backpropagate through random sampling.
The paper ↗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 ↗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 ↗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 c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.