TAI BUI
Back to curriculum
BuildPython

Autoencoders & Variational Autoencoders (VAE)

A plain autoencoder compresses, then reconstructs. It memorizes and does not generate. A VAE adds one trick: it forces the code to look Gaussian, and that gives you a sampler. The reparameterization `z = μ + σ·ε` is the reason the latent-diffusion and flow-matching image models you use in 2026 have a VAE at the input.

TIME: ~75 minutesPREREQUISITES: Phase 3 · 02 (Backprop), Phase 3 · 07 (CNNs), Phase 8 · 01 (Taxonomy)

Learning Objectives

  • Explain why a plain autoencoder cannot generate, and how a VAE's KL penalty toward N(0, I) turns the code space into something you can sample from.
  • Write the reparameterization z = μ + σ·ε and say why it lets gradients flow through a sampling step.
  • Read the VAE loss (reconstruction + β·KL, with the closed-form Gaussian KL) and predict what raising or lowering β does to samples and the code space.
  • Run the pure-Python VAE in main.py and map its forward, loss_value, backward and apply_update functions onto the lesson's math.
  • Recognize posterior collapse and blurry samples, name the standard fixes, and place the VAE inside a latent-diffusion pipeline.

The Problem

An autoencoder compresses an input to a short code and reconstructs it from that code. A variational autoencoder (VAE) is an autoencoder whose encoder outputs a distribution over codes instead of a single code, with a penalty that pulls that distribution toward a Gaussian prior so you can sample from it.

Start with the plain version. Compress a 784-pixel MNIST digit to a 16-number code, then reconstruct. (That is the motivating picture. The main.py you will run is a much smaller version: 8-D synthetic data from a two-cluster Gaussian mixture, a 2-D latent, a hidden layer of 10 units, 60 samples, 40 epochs, and β fixed at 0.2.) A plain autoencoder does well on reconstruction MSE, but the code space is lumpy. Pick a random point in it, decode it, and you get noise. There is no sampler here, just a compression model.

What you want is three things at once: (a) a code space that is a clean, smooth distribution you can sample from, say an isotropic Gaussian N(0, I), (b) decoded samples that look like plausible digits, and (c) an encoder and decoder that still compress well. Three goals, one architecture, one loss.

Kingma's 2013 VAE gets there by training the encoder to output a distribution q(z|x) = N(μ(x), σ(x)²), pulling it toward the prior N(0, I) with a KL penalty, and sampling z from q(z|x) before decoding. At inference time you drop the encoder, sample z ~ N(0, I), and decode. The KL penalty is what structures the code space.

In 2026 VAEs rarely ship standalone, since diffusion has outclassed them on raw image quality. They are the encoder of choice for latent-diffusion models (SD 1/2/XL/3, Flux, AudioCraft). Learn the VAE and you learn the invisible first layer of the image pipelines you use.

Warm-up0/3
1. A network is trained to copy its input to its output through a much narrower middle layer. What does that narrow layer force the network to do?Warm-up
A small network drawn as three columns of circles: six input units, a much narrower middle layer of two units, and six output units, with the training target noted as output equal to input.
Illustrative layer sizes.
2. For backprop to update weights early in a network, what must be true of every operation on the path from the loss back to those weights?Warm-up
A chain of five boxes: weights w, operation 1, operation 2, operation 3, loss, with a forward arrow row left to right and a backward arrow row from the loss toward w.
Illustrative chain.
3. A number is drawn from a standard normal distribution. What are its mean and variance?Warm-up
A histogram of 2000 random draws on an axis from -3 to 3, bell shaped.
Histogram of random.Random(0).gauss(0, 1), 2000 draws, bin width 0.25.
Check: The Problem0/4
1. What is an autoencoder?Define
A chain: x (784 pixels) goes to an encoder, then z (16 numbers), then a decoder, then x-hat (784 pixels), with the training loss written as the squared difference between x and x-hat.
Sizes from the lesson's MNIST picture.
2. What is the key difference between a plain autoencoder and a VAE?Define
Two identical pipelines, one labelled plain autoencoder and one labelled VAE: x into an encoder, then a box labelled encoder output with a question mark, then a decoder and x-hat.
Both models, drawn the same way.
3. Why can a plain autoencoder not be used to generate new samples?Explain
Two paths through a plain autoencoder: a 784-pixel input is encoded to a 16-number code and decoded with low reconstruction error, while a random 16-number vector fed to the decoder gives an output left as a question mark.
Sizes from the lesson; MSE described there as good.
4. A plain autoencoder reconstructs digits well, but decoding a random 16-number vector gives noise. What is the diagnosis?Analyze
A strip of 16 random numbers fed to the decoder of a plain autoencoder, with the output drawn as an 8 by 8 patch of random gray noise.
The 16 numbers are random.Random(5).gauss draws; the noise patch is drawn at random to match the stem, not decoded.

The Concept

Autoencoder vs VAE: the reparameterization trick

Autoencoder. z = encoder(x), x̂ = decoder(z), loss = ||x - x̂||². The code space has no structure.

VAE encoder. Outputs two vectors, μ(x) and log σ²(x), which define q(z|x) = N(μ, diag(σ²)).

Reparameterization trick. Sampling from q(z|x) is not differentiable. Rewrite the sample as z = μ + σ·ε where ε ~ N(0, I). Now z is a deterministic function of (μ, σ) plus noise that has no parameters, so gradients flow through μ and σ.

Loss. The Evidence Lower BOund (ELBO) has two terms:

loss = reconstruction + β · KL[q(z|x) || N(0, I)]
     = ||x - x̂||²  + β · Σ_i ( σ_i² + μ_i² - log σ_i² - 1 ) / 2

Reconstruction pushes x̂ toward x. KL pushes q(z|x) toward the prior. They trade off. Small β (<1) gives sharper samples and a code space that is less Gaussian. Large β (>1) gives a cleaner code space and blurrier samples. β-VAE (Higgins 2017) made this knob famous and kicked off disentanglement research.

Sampling. At inference, draw z ~ N(0, I) and run it through the decoder. That is one forward pass, with no iterative sampling like diffusion.

Check: The Concept0/8
1. What is the prior in a VAE?Define
A 2-D code-space plot with two red circles marking the 1 and 2 standard-deviation contours of N(0, I) and twelve small orange and blue ellipses showing the encoder's q(z|x) for twelve training inputs.
Real: encoder outputs of the lesson's toy VAE after 40 epochs. Orange and blue are the two data clusters.
2. What is the reparameterization trick?Define
A training graph: x into an encoder producing mu and sigma, then a highlighted box that draws z from N(mu, sigma squared), then decoder and loss. Text says the gradient arrives at z and the encoder weights behind mu and sigma still need updating.
The sampling step is drawn as a black box.
3. What are the two terms of the loss in the Concept section's ELBO?Define
The VAE pipeline x, encoder, q(z|x), z, decoder, x-hat, above a box saying loss equals term 1 plus term 2, with the terms left unnamed.
Terms left blank on purpose.
4. What is a β-VAE?Define
A code panel with lines from main.py: beta equals 0.2, lr equals 0.01, then loss_value and backward called with beta as an argument.
Excerpt of the lesson's main.py; lines between are elided.
5. Why does the KL penalty toward N(0, I) make generation possible at inference?Explain
Two rows. Training: x, encoder, q(z|x), decoder, x-hat with a KL penalty toward N(0, I) added to the loss. Inference: the encoder is crossed out, z is drawn from N(0, I), then the decoder produces a new sample.
The lesson's training and inference descriptions.
6. Why does writing z = μ + σ·ε let gradients flow through a sampling step?Explain
An encoder output of mu equal to [1, -1] and sigma equal to [1, 1], and three different z vectors sampled from it, with a note that the encoder weights behind mu and sigma are what training has to update.
Three draws from random.Random(2).gauss, shown for illustration.
7. In main.py you raise the hardcoded beta from 0.2 to 5.0. What do you expect from the printed columns and the code space?Apply
Terminal output of the lesson's main.py with beta 0.2: eight epoch lines of loss, recon and KL ending at epoch 40 with loss 0.654, recon 0.369, KL 1.428, and a note about editing beta to 5.0.
Real output of the lesson's main.py.
8. A VAE trained with a very small β reconstructs sharply, but digits decoded from N(0, I) draws look broken. Which explanation fits?Analyze
A VAE with a very small beta: reconstruction through encoder and decoder is sharp, while decoding z drawn from N(0, I) gives digits that look broken.
The stem's situation, stated in a diagram.

Build It

code/main.py implements a tiny VAE without numpy or torch. Input is 8-dimensional synthetic data drawn from a 2-component Gaussian mixture in 8-D. Encoder and decoder are single hidden-layer MLPs. We implement tanh activation, forward pass, loss, and a hand-written backward pass. This is for teaching, not production.

The snippets below are illustrative. main.py has no functions named encode, reparameterize, decode, elbo or sample. The real ones are forward (encoder, reparameterization and decoder in a single pass), loss_value (the ELBO), backward (hand-written gradients) and apply_update (the SGD step), and main() decodes its N(0, I) samples inline. The β in the file is a local variable, beta = 0.2, not a function argument.

Step 1: encoder forward

python
def encode(x, enc):
    h = tanh(add(matmul(enc["W1"], x), enc["b1"]))
    mu = add(matmul(enc["W_mu"], h), enc["b_mu"])
    log_sigma2 = add(matmul(enc["W_sig"], h), enc["b_sig"])
    return mu, log_sigma2

log σ² instead of σ so the network output is unconstrained (softplus of σ is a trap: gradients die at σ ≈ 0). The real forward also clamps log σ² to [-6, 6] to keep σ in a sane range; the snippet above leaves that out.

Step 2: reparameterize and decode

python
def reparameterize(mu, log_sigma2, rng):
    eps = [rng.gauss(0, 1) for _ in mu]
    sigma = [math.exp(0.5 * lv) for lv in log_sigma2]
    return [m + s * e for m, s, e in zip(mu, sigma, eps)]
 
def decode(z, dec):
    h = tanh(add(matmul(dec["W1"], z), dec["b1"]))
    return add(matmul(dec["W_out"], h), dec["b_out"])

Step 3: the ELBO

python
def elbo(x, x_hat, mu, log_sigma2, beta=1.0):
    recon = sum((a - b) ** 2 for a, b in zip(x, x_hat))
    kl = 0.5 * sum(math.exp(lv) + m * m - lv - 1 for m, lv in zip(mu, log_sigma2))
    return recon + beta * kl, recon, kl

The KL has an exact closed form because both distributions are Gaussian, so there is no need to integrate numerically. People still ship code with Monte-Carlo KL estimates in 2026, and it is 3x slower for no reason. (That speed claim is prose only; main.py never compares the two estimators.)

Step 4: generate

python
def sample(dec, z_dim, rng):
    z = [rng.gauss(0, 1) for _ in range(z_dim)]
    return decode(z, dec)

That is the generative model: three lines. In main.py the same step runs inline in main(), which draws z from N(0, I) and pushes it through the decoder weights.

Check: Build It0/9
1. Why does the VAE encoder output log σ² instead of σ?Explain
Lines from forward in main.py: the encoder head producing log_sigma2 clamped to -6 and 6, then sigma computed as exp of half log_sigma2, then z computed from mu, sigma and eps.
Excerpt of the lesson's main.py.
2. Why can the KL term in the ELBO be computed in closed form?Explain
Two density curves over z: an orange q(z|x) and a red prior, with the KL between them given as 0.818 from numerical summation.
Illustrative curves (q has mean 1.0 and sigma 0.5, the prior has mean 0 and sigma 1); the KL is computed here by numerical summation.
3. Using the elbo snippet with x = [1, 0], x_hat = [0.5, 0], mu = [1, 0], log_sigma2 = [0, 0] and beta = 0.5, what total does it return?Apply
A table of inputs: x [1, 0], x_hat [0.5, 0], mu [1, 0], log_sigma2 [0, 0], beta 0.5, above the elbo snippet from the lesson.
Snippet text from the lesson's Build It section.
4. The encoder outputs mu = [0, 0] and log_sigma2 = [0, 0]. What KL does the elbo snippet compute?Apply
A table with mu [0, 0] and log_sigma2 [0, 0] above the two-line KL expression from the lesson's elbo snippet.
Snippet text from the lesson's Build It section.
5. With mu = [1.0, -1.0], log_sigma2 = [0, 0] and eps = [0.5, -2.0], what does reparameterize return?Apply
A table with mu [1.0, -1.0], log_sigma2 [0, 0] and eps [0.5, -2.0] above the sigma and return lines of the lesson's reparameterize snippet.
Snippet text from the lesson's Build It section.
6. In main.py, which function computes the ELBO (reconstruction plus β times KL)?Apply
Four steps of one training step in order: forward pass, a highlighted step marked with a question mark taking x, forward results and beta and returning total, recon and kl, hand-written gradients, and an SGD update.
Roles and argument lists from the lesson's main.py; step 2's name is hidden.
7. You want to rerun main.py with β = 1.0. Where does the file set β?Apply
An outline of main.py listing six function definitions with their line numbers, next to terminal output whose header line shows beta equal to 0.2, and the question of which place in the file to edit.
Line numbers read from the lesson's main.py.
8. What does the sample function in Build It need in order to generate a new vector?Apply
A random 2-D z vector going into a decoder and producing a new 8-D vector, with the numbers from the first sample line printed by the lesson's main.py.
Real output of the lesson's main.py (first sample line).
9. A teammate estimates the KL with Monte-Carlo samples even though q(z|x) and the prior are both Gaussian. What is the consequence?Analyze
Two Gaussian curves, an orange q(z|x) and a red prior, with five white dots on the axis marking sampled z values, and a note that the estimate averages log q minus log p over those samples.
Illustrative mu and sigma; the five z values are real random draws.

Pitfalls

  • Posterior collapse. The KL term drives q(z|x) → N(0, I) so aggressively that z carries no info about x. Fix: β-annealing (start β=0, ramp to 1), free bits, or skip the KL on inactive dimensions.
  • Blurry samples. The Gaussian decoder likelihood implies MSE reconstruction, which is Bayes-optimal for L2 (the mean). The mean of a set of plausible digits is a fuzzy digit. Fix: discrete decoder (VQ-VAE, NVAE), or use the VAE as a compressor and run diffusion on its latents (this is what Stable Diffusion does).
  • β too large, too early. See posterior collapse. Start at β≈0.01 and ramp.
  • Latent dim too small. 16-D works for MNIST, 256-D for ImageNet 256², 2048-D for ImageNet 1024². Stable Diffusion's VAE compresses 512×512×3 → 64×64×4 (each side shrinks 8x, so 64x in spatial area, while channels go from 3 to 4, which makes the total element count about 48x smaller).

The fixes above (β-annealing, free bits, skipping the KL on inactive dimensions) are prose only. main.py holds β fixed at 0.2 with no ramp, so none of them appear in the file.

Check: Pitfalls0/6
1. What is posterior collapse?Define
Two 8-D inputs, the two cluster centers of the lesson's data, each going through an encoder to outputs mu and log sigma squared left as question marks.
Cluster centers from sample_mixture in the lesson's main.py.
2. Why do VAEs with a Gaussian decoder likelihood produce blurry samples?Explain
Four small black-and-white 5 by 7 glyphs of a vertical stroke, one straight, one leaning right, one leaning left and one bowed, labelled plausible digits for the same code, next to a box for a Gaussian decoder likelihood with MSE and an output left as a question mark.
Typed toy glyphs, not model output.
3. What goes wrong when β is set too large too early in training?Explain
A plot of beta over training: a dashed line at 1 and an orange line well above it, flat from the first step to the end, labelled beta large from the first step.
Schematic; the value of beta is only shown as above 1.
4. Why does β-annealing, starting β at 0 and ramping to 1, help avoid posterior collapse?Explain
A line plot of beta: starting at 0, rising linearly to 1, then staying at 1 until the end.
Schematic of the ramp described in the lesson; the ramp length is illustrative.
5. Stable Diffusion's VAE maps a 512×512×3 image to 64×64×4. How do the spatial sides and channels change?Apply
A large square labelled 512 by 512 by 3 image, an arrow labelled VAE, and a much smaller square labelled 64 by 64 by 4 latent, drawn with the side lengths to scale.
Side lengths 512 and 64 drawn to scale.
6. You run main.py and watch the printed KL column sink toward zero while reconstruction error stays high. What is most likely happening?Analyze
Eight epoch lines from a run of main.py: the recon column stays near 8.3 and the KL column falls to 0.000.
Real output of the lesson's main.py with the hardcoded beta changed.

Use It

The 2026 VAE stack:

SituationPick
Image-latent encoder for diffusionStable Diffusion VAE (sd-vae-ft-ema) or Flux VAE
Audio-latent encoderEncodec (Meta), SoundStream, or DAC (Descript)
Video latentsSora's spatiotemporal patches, Latte VAE, WAN VAE
Disentangled representation learningβ-VAE, FactorVAE, TCVAE
Discrete latents (for transformer modelling)VQ-VAE, RVQ (ResidualVQ)
Continuous latents for generationPlain VAE, then condition a flow/diffusion model in that latent space

A latent-diffusion model is a VAE with a diffusion model living between encoder and decoder. The VAE does coarse compression, the diffusion model does the heavy lifting. Same pattern for video (VAE + video-diffusion DiT) and audio (Encodec + MusicGen transformer).

Check: Use It0/4
1. What is a VQ-VAE?Define
A 2-D plane with five blue codebook vectors e0 to e4 and one orange encoder output z_e placed among them.
Illustrative positions, typed by hand.
2. In a latent-diffusion model, how are the VAE and the diffusion model divided?Explain
A pipeline: a 512 by 512 by 3 image, a VAE encoder, a highlighted diffusion model, a VAE decoder, and a 512 by 512 by 3 image.
Block order from the lesson: the diffusion model sits between VAE encoder and decoder.
3. You need high-quality images from a text prompt on a limited compute budget. Which design follows the lesson's pattern?Design
A text prompt on the left, a highlighted box labelled design to be chosen with a question mark, and a high-quality image on the right, with a note listing the parts covered: VAE, plain autoencoder and diffusion model.
Setup only.
4. You want a transformer to model audio as a sequence of tokens. Which latent choice from the Use It table fits?Design
A sine-like audio waveform feeding a highlighted box labelled latent choice with a question mark, which produces a sequence of tokens that goes into a transformer.
Waveform is illustrative.

Ship It

Save outputs/skill-vae-trainer.md.

Skill takes: dataset profile + latent-dim target + downstream use (reconstruction, sampling, or latent-diffusion input) and outputs: architecture choice (plain/β/VQ/RVQ), β schedule, latent dim, decoder likelihood (Gaussian vs categorical), and evaluation plan (recon MSE, KL per dim, Fréchet distance between q(z|x) and N(0, I)).

Author's Note

If I were working through the exercises, I'd start with the β sweep. The file trains with beta fixed at 0.2, and the balance between the reconstruction term and the KL term is set by that one number. Moving it and watching the printed recon and KL columns shows the balance directly, which the loss formula alone does not. With a large β, the KL column sinks toward zero, which is what the posterior collapse from Pitfalls looks like in a run.

My bet is that two choices here are worth copying into the VAEs you write later: the network predicts log σ² instead of σ, and the KL uses the closed-form Gaussian expression. Both are in the file in a few lines each.

I'd also keep the toy in proportion. The data is 8-D with two clusters and the latent is 2-D, so a decoder that turns N(0, I) samples into vectors near those clusters shows the sampler works. It does not show what a VAE does to MNIST digits, and the blur discussed in Pitfalls is something you have to go find with a larger model.

Exercises

  1. Easy. Edit the hardcoded beta = 0.2 line in main() of code/main.py and run it with 0.01, 0.1, 1.0, 5.0. Record the final reconstruction MSE and KL. Which β is Pareto-best for your synthetic data?
  2. Medium. Replace the Gaussian decoder likelihood with a Bernoulli likelihood (cross-entropy loss). Compare sample quality on a binarized version of the same synthetic data.
  3. Hard. Extend code/main.py into a mini VQ-VAE: replace the continuous z with a nearest-neighbour lookup in a codebook of K=32 entries. Compare reconstruction MSE and report how many codebook entries get used (codebook collapse is real).

Key Terms

TermWhat people sayWhat it actually means
AutoencoderEncode-decode networkx → z → x̂, learn MSE. Not generative.
VAEAE with a samplerEncoder outputs a distribution, KL penalty shapes code space.
ELBOEvidence lower boundlog p(x) ≥ recon - KL[q(z|x) || p(z)]; tight when q = p(z|x).
Reparameterizationz = μ + σ·εRewrites stochastic node as deterministic + pure noise. Enables backprop through sampling.
Priorp(z)Target distribution for the latent, typically N(0, I).
Posterior collapse"KL term wins"Encoder ignores x, outputs the prior; decoder must hallucinate.
β-VAETunable KL weightloss = recon + β·KL. Higher β = cleaner code space but blurrier samples (the knob behind disentanglement research).
VQ-VAEDiscrete latentReplace continuous z with nearest codebook vector; enables transformer modelling.

Production note: the VAE is the hottest path in a diffusion server

In a Stable Diffusion / Flux / SD3 pipeline the VAE is called twice per request: once to encode (if doing img2img / inpainting) and once to decode. At 1024² the decoder pass is often the single largest activation-memory peak in the whole pipeline because it upsamples 128×128×16 latents back to 1024×1024×3. Two practical consequences:

  • Slice or tile the decode. diffusers exposes pipe.vae.enable_slicing() and pipe.vae.enable_tiling(). Tiling trades a small seam artifact for O(tile²) memory instead of O(H·W). Tiling matters for 1024²+ on consumer GPUs.
  • bf16 decoder, fp32 numerics for the final resize. The SD 1.x VAE was released in fp32 and silently produces NaNs when cast to fp16 at 1024²+. SDXL ships madebyollin/sdxl-vae-fp16-fix. Prefer the fp16-fix variant or use bf16.
Check: Production note: the VAE is the hottest path in a diffusion server0/3
1. Why is the VAE decoder pass often the largest activation-memory peak in a 1024² diffusion pipeline?Explain
A pipeline of five boxes for one 1024 squared request: prompt, text encoder, denoising loop, VAE decode (highlighted, marked as the activation-memory peak), and the 1024 squared image.
Stage order from the lesson's production note.
2. You cast the original SD 1.x VAE to fp16 and decode at 1024². No error is raised, but the output images are broken. What is the most likely cause?Analyze
A flow: original SD 1.x VAE, cast to fp16, decode at 1024 squared. Below, a panel reading no exceptions and no warnings, and a panel reading output images broken, cause unknown.
The stem's situation.
3. A 1024² decode runs out of memory on a consumer GPU, and the images are fine otherwise. Which change fits?Design
A latent going through a VAE decode box into an out-of-memory error at 1024 squared on a consumer GPU, with notes that the other stages and any images that do finish are fine.
The stem's situation.

Further Reading

Code

Ships With