← Back to Writings

Generative models for robots

I’ll work through variational autoencoders and derive the ELBO step by step. Then I’ll cover the reparameterization trick, posterior collapse, conditional VAEs, and GANs: their objective, mode collapse and why they are hard to train. Every example is a robot that has to choose, grasp, or move. I’ll start with the intuition and build toward the mathematical details.

Start here: why a robot has to generate, not just predict

Imagine a robot arm that has watched a person reach across a table two hundred times. A cup sits in the way. About half the time the person reached around it on the left, half the time on the right. Now the robot has to reach. The obvious approach, learn to predict the demonstrated motion from the situation, has a failure built in: the best single prediction of a left-or-right motion is the average of the two, and the average goes straight through the cup.

This is the problem I want to work through. Whenever the data has several right answers, a model that outputs one answer learns the wrong one. A generative model instead learns the whole range of plausible answers and lets you draw one at random: sometimes left, sometimes right, never through the cup.

Left or right around the cup

A corridor seen from above. The arm starts at the bottom, must reach the goal at the top, and a cup (the circle) sits in the way. Move the cup, then compare what a predictor learns with what a generative model learns. Both networks below were trained on the same demonstrations, with the exact losses this page will derive; their weights are embedded in the page so the demo is instant.

cup position: 0.00

Trajectories that pass through the cup are drawn in red. The “best single prediction” is a network trained to minimize squared error; the generative model is a conditional VAE from chapter 6.

Robotics is full of this. An object can be grasped a dozen ways. A pedestrian might turn left or right. A simulated camera image has many plausible real-world counterparts. In each case the robot needs a model that can propose a good answer and, if asked again, a different good one. I’ll build up these models in the following order:

  1. The basics you need: probability distributions, likelihood, the KL divergence, and one inequality. Everything else is built from these.
  2. Hidden causes: the idea of a latent variable, and why the obvious way to train such a model is impossible.
  3. The ELBO: the bound that makes it possible, derived one step at a time until you can say it out loud.
  4. The VAE: the reparameterization trick that lets gradients through a random draw, and a VAE you can train in your browser.
  5. Posterior collapse: the way VAEs fail, how to recognize it, and what to do about it.
  6. Conditional VAEs: generating given a situation, which is what a robot actually needs.
  7. GANs: a completely different way to learn to generate, with a two-player objective, and why the game is hard to keep stable.

Next: The basics you need

1. The basics you need

I’ll start with four ideas that the rest of the discussion builds on. Each gives us a piece we’ll need to understand how these models learn.

A distribution is a landscape over possible outcomes

Point a laser range finder at a wall exactly 2.0 m away and read it 25 times. You will not get 2.000 twenty-five times. You get 1.97, 2.04, 2.01: a cluster around 2.0 with some spread. A probability distribution describes this cluster. For continuous quantities it is drawn as a density, a curve that is tall where readings are common and low where they are rare. The area under the curve between two values is the probability of a reading landing between them; the whole area is 1. Writing p(x) means “the density at value x”.

Drawing a value at random from a distribution is called taking a sample. Tall parts of the curve produce samples often, low parts rarely. The bell-shaped curve you get from most sensors is the Gaussian, written N(μ, σ²): μ is the center, σ the width.

1.8 m2.0 m2.2 m 25 readings area = probability of a reading in [1.9, 2.0] density p(x)
The density is the smooth curve; the readings are the ticks. Where the curve is tall, ticks are dense. The shaded area is a probability; the height of the curve on its own is not.
Worked example: density is not probability

The Gaussian density is a formula, and it is worth having seen it written out once, because every log-likelihood on this page is this formula with a log in front:

N(x; μ, σ²) = 1 / (σ√(2π)) · exp( −(x − μ)² / 2σ² )

Take the laser sensor: μ = 2.0 m, σ = 0.05 m. Plug in numbers:

valuewhat it isnumber
density at 2.0 m1 / (0.05 · 2.5066)7.98
density at 2.1 m7.98 · exp(−0.01 / 0.005) = 7.98 · exp(−2)1.08
P(1.95 < x < 2.05)area under the curve within ±1σ0.683
P(1.90 < x < 2.00)area from −2σ to the center (the shaded region in the figure)0.477

Two things to notice. The density at the peak is 7.98, bigger than 1: a density is not a probability, and only areas are. And the probability of the exact value 2.000 is zero (an area of zero width); a sensor reading is always a question about a range. When chapter 1 later says “log p(x)” it means the log of the density, 7.98 → log 7.98 = 2.08, which is why log-likelihoods can be positive.

Sampling: how a computer actually draws from N(μ, σ²)

Draw a standard normal ε (mean 0, spread 1), then stretch and shift it: x = μ + σ·ε. Every library does this, and chapter 4 will build the reparameterization trick on nothing more. The page’s own gaussRnd makes ε with the Box–Muller formula, ε = √(−2 ln u₁) · cos(2πu₂), from two uniform numbers u₁, u₂ in (0, 1).

import numpy as np
rng = np.random.default_rng(0)
readings = 2.0 + 0.05 * rng.standard_normal(25)     # mu + sigma * eps, 25 times
# the same thing, spelled out
p = rng.random(25); q = rng.random(25)
eps = np.sqrt(-2 * np.log(1 - p)) * np.cos(2 * np.pi * q)       # Box-Muller
readings2 = 2.0 + 0.05 * eps

# density and log-density of a value under N(mu, sigma^2)
def log_normal(x, mu, sigma):
    return -0.5 * ((x - mu) / sigma) ** 2 - np.log(sigma) - 0.5 * np.log(2 * np.pi)
print(np.exp(log_normal(2.0, 2.0, 0.05)))   # 7.9788
print(log_normal(2.0, 2.0, 0.05))           # 2.0768

Likelihood: how well a model explains the data

Suppose you propose a Gaussian with some μ and σ as a model of the sensor. How good is the proposal? Compute the density the model assigns to each reading, and multiply them all together. That product is the likelihood of the data under the model. It is tiny (25 numbers below 1 multiplied together), so everyone works with its logarithm instead: the log turns the product into a sum, and log p(x) is called the log-likelihood. Bigger is better. Picking the parameters that make the log-likelihood as large as possible is maximum likelihood training, and it is what every model on this page is trying to do, or trying to approximate.

Fit a Gaussian to lidar readings

The 25 readings from the figure. Move the two sliders to make the readings as probable as you can under a Gaussian. The number to maximize is the total log-likelihood: the sum of log p(reading) over all 25 readings.

mean μ: 1.80 m
spread σ: 0.30 m
total log-likelihood
best achievable

Worked example: three readings, three candidate models

Use just the first three readings, 1.97, 2.04 and 2.01 m, so everything fits in a table. Score three candidate Gaussians by the log-density they give each reading, then add up.

candidate modellog p(1.97)log p(2.04)log p(2.01)total log-likelihoodlikelihood (product)
N(2.0, 0.05²)1.8971.7572.0575.71e5.71 ≈ 302
N(1.9, 0.05²), center off by 0.11.097−1.843−0.343−1.090.34
N(2.0, 0.2²), four times too wide0.6790.6700.6892.047.7

Read the second row: moving the center to 1.9 makes the reading 2.04 sit 2.8σ away, and its log-density drops from 1.76 to −1.84. One badly explained reading costs more than the other two can make up. Read the third row: a wide bell never gets a terrible score but never gets a good one either, because its peak is only 1/(0.2·√(2π)) = 1.99 high. The best possible parameters (the maximum likelihood estimate) are the sample mean μ̂ = 2.007 and the sample spread σ̂ = 0.029, scoring 6.40; that is what the “best achievable” number in the bench above reports for all 25.

Why the log. With 25 readings, the product of 25 densities of about 3 each is 325 ≈ 8 × 1011; with densities of about 0.5 it is 0.525 ≈ 3 × 10−8. With 1000 readings both overflow or underflow a floating point number. The sum of logs, 25 · 1.1 = 27.5 or 25 · (−0.69) = −17, is harmless. Since log is increasing, the parameters that maximize the sum also maximize the product; nothing is lost.

x = np.array([1.97, 2.04, 2.01])
for mu, sigma in [(2.0, 0.05), (1.9, 0.05), (2.0, 0.2)]:
    ll = log_normal(x, mu, sigma)             # one log-density per reading
    print(mu, sigma, ll.round(3), ll.sum().round(2))
# maximum likelihood by hand: mean and (population) standard deviation
mu_hat, sigma_hat = x.mean(), x.std()          # 2.0067, 0.0287

A neural network can play the role of the sliders. Give it whatever the distribution should depend on (the robot's joint angles, a camera image, a goal) and let it output μ and σ. Training by maximum likelihood then nudges the network's weights so that the observed outcomes get high density. This is how a network “outputs a distribution”: it outputs the parameters of one.

How it is implemented: a network that outputs μ and σ

The network has two output units per predicted quantity: one is read as the mean μ, the other as log σ (or log σ²). Taking the exponential guarantees a positive spread whatever the network outputs. The loss is the negative log-likelihood of the observed outcomes under N(μ(c), σ(c)²), averaged over the batch. This is the whole of “probabilistic regression”, and the decoder of every VAE on this page is this idea with z as the input.

import torch, torch.nn as nn

class GaussianHead(nn.Module):
    def __init__(self, n_in, n_out):
        super().__init__()
        self.net = nn.Sequential(nn.Linear(n_in, 64), nn.Tanh(), nn.Linear(64, 2 * n_out))
    def forward(self, c):
        mu, log_sigma = self.net(c).chunk(2, dim=-1)   # split the output in two halves
        return mu, log_sigma.exp()                     # exp() makes sigma positive

model = GaussianHead(n_in=3, n_out=1)                  # e.g. 3 joint angles -> 1 sensor reading
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
for c, x in loader:                                    # batches of (situation, outcome)
    mu, sigma = model(c)
    # sum over dims, mean over batch
    nll = -torch.distributions.Normal(mu, sigma).log_prob(x).sum(-1).mean()
    opt.zero_grad(); nll.backward(); opt.step()

Normal(mu, sigma).log_prob(x) is exactly the log_normal function above; the library only saves you from typing it. With σ held fixed, minimizing this loss is minimizing squared error (chapter 2 shows why); letting the network output σ as well lets it say how sure it is.

Expectation: the average under a distribution

The expectation of a quantity f(x) under a distribution p, written Ep[f(x)], is its average when x is drawn from p: the values f takes, weighted by how often they occur. You rarely compute it exactly. You estimate it by drawing samples x1, …, xn from p and averaging f(xi). This is a Monte Carlo estimate; it is unbiased, and it gets better slowly, with error shrinking like 1/√n. Chapter 3 will estimate an expectation with a single sample and get away with it.

Worked example: what an expectation is, and what “estimate it with samples” looks like with numbers
1. The definition, in a case you can do by hand

Suppose the robot’s model says: 45% of the time the arm goes left of the cup, 45% right, 10% straight through. Let f be the cost of the outcome: 0 for left, 0 for right, 1 for a collision. The expectation of the cost is each value of f weighted by how often it occurs:

Ep[f] = 0.45·0 + 0.45·0 + 0.10·1 = 0.10

That is all “E” ever means: a weighted average, with the weights being probabilities. For a continuous x the sum becomes an integral, Ep[f(x)] = ∫ f(x) p(x) dx, and for most f and p nobody can do the integral. Hence the next step.

2. Estimating it by sampling: E[z²] under N(0, 1)

Pick a case where the true answer is known so the estimate can be checked. If z ~ N(0, 1), then E[z²] = 1 exactly (it is the definition of the variance). Now pretend you did not know that. Draw five values of z, square each, average:

drawzz²
10.620.384
2−1.341.796
30.090.008
41.813.276
5−0.470.221
average1.137

The Monte Carlo estimate with n = 5 is 1.137; the truth is 1; the error is 0.137. Notice how different the five terms are (0.008 to 3.276): a single term on its own would be a terrible guess. Now let a computer do it, in six separate experiments of increasing size (one fixed random seed, so the numbers are reproducible):

n samplesestimate of E[z²]errortypical error, √2 / √n
10.0160.981.41
100.5940.410.45
1000.9850.0150.14
1,0000.9750.0250.045
10,0001.0000.000030.014
100,0001.00140.00140.0045

The last column is the theory. Each term z² has its own spread (standard deviation √2 ≈ 1.41 for this f), and the average of n independent terms has spread √2 / √n. That is the 1/√n in the text: to make the error ten times smaller you need a hundred times more samples. The actual errors in the third column scatter around the typical error, sometimes above, sometimes below, which is what “typical” means.

3. What “unbiased” means, and why one sample can be enough

Unbiased means: the estimate is not systematically too high or too low. If you repeated the whole experiment many times and averaged the estimates, you would get exactly the truth. Even the n = 1 estimate is unbiased: a single z² is 0.016 this time, maybe 3.3 next time, but on average over repeats it is 1. It is just very noisy.

This is why chapter 3 can “estimate an expectation with a single sample and get away with it”. Training takes thousands of gradient steps, each with a freshly drawn z. The noise of each step is huge, but the steps are unbiased, so their errors point in random directions and cancel while the true signal accumulates, exactly like the running average above reaching 1.000 after 10,000 draws. A minibatch gradient in ordinary deep learning is the same kind of object: an unbiased, noisy estimate of the full-dataset gradient, and nobody waits for it to be exact before stepping. (The crash course has more on this.)

4. The needle from chapter 2, previewed

Change f to the indicator “1 if z is within 0.05 of 1.3, else 0”. Its expectation is the probability of that event, 0.0171. With n = 10 the estimate is almost always exactly 0, because none of the ten draws landed in the window; unbiased, and useless. That is the situation chapter 2 runs into when it tries to average over the prior, and the reason an encoder is needed. Try it in the bench below.

Estimate an expectation by drawing samples

Pick a quantity f(z) with z ~ N(0, 1) whose true expectation is known. Draw samples and watch the running average. The plot shows the error of the estimate against the number of samples on log–log axes; the dashed line is the typical error, spread(f) / √n, which appears as a straight line of slope −½. Every error you see is one particular run; Reset re-rolls the dice.

quantity
samples n0
last single sample f(zn)
running average, the estimate
truth
error
typical error spread(f)/√n

How it is implemented: a Monte Carlo estimate is three lines
import numpy as np
rng = np.random.default_rng(0)

def expectation(f, n):
    z = rng.standard_normal(n)          # 1. draw n samples from p (here N(0, 1))
    fz = f(z)                           # 2. evaluate f at each sample
    est = fz.mean()                     # 3. average: the Monte Carlo estimate
    std_err = fz.std() / np.sqrt(n)     #    the typical error, estimated from the same samples
    return est, std_err

print(expectation(lambda z: z ** 2, 1000))                     # e.g. (0.975, 0.045); truth 1
# almost always (0.0, 0.0); truth 0.0171
print(expectation(lambda z: np.abs(z - 1.3) < 0.05, 10))

Line 3 is the entire method. The standard error in line 4 needs no knowledge of the truth, which is how you know when to stop drawing in practice. In a VAE, p is the encoder’s Gaussian, f is “run the decoder and score the reconstruction”, and n is 1.

KL divergence: how far one distribution is from another

Often two distributions describe the same quantity: your model's and the true one, or a convenient one and the one you actually want. The KL divergence measures how different they are:

KL(qp) = Eq[ log q(x) − log p(x) ]

  • Draw x from q. Compare how surprised you are under q versus under p (log density is a measure of surprise, with the sign flipped). Average over draws.
  • It is always ≥ 0, and equals 0 only when q and p are the same distribution. I’ll use that fact throughout the derivation.
  • It is not symmetric: KL(qp) and KL(pq) differ. The one written here, with the expectation taken under q, is the one that appears in the ELBO.

KL divergence between two Gaussians

p is fixed at N(0, 1), in grey. Shape q with the sliders and watch KL(qp) change. The KL between two Gaussians has a closed form, which chapter 4 will use; here it is simply computed.

q mean μ: 1.00
q spread σ: 0.60
KL(qp)
KL(pq), the other direction

Set μ = 0 and σ = 1 to make the curves coincide: both numbers become 0. Everywhere else the two directions disagree, which is why KL is called a divergence and not a distance. A narrow q sitting inside a wide p costs little in the first direction and a lot in the second.

Worked example: KL with numbers, discrete and Gaussian
1. Three outcomes

Let the demonstrations be p = (left 0.45, right 0.45, through 0.10) and a robot’s model be q = (0.5, 0.5, 0). KL is a weighted sum of log-ratios; the weights come from the first argument.

KL(qp) = 0.5·log(0.5/0.45) + 0.5·log(0.5/0.45) + 0·log(0/0.10) = 0.105 + 0 = 0.105 nats

KL(pq) = 0.45·log(0.45/0.5) + 0.45·log(0.45/0.5) + 0.10·log(0.10/0) =

Same two distributions, two very different answers. The rule behind it: KL(ab) explodes wherever a puts probability and b puts none, and it hardly notices places where b has probability and a does not (the term 0 · log 0 counts as 0). Chapter 3’s ELBO uses KL(q ‖ posterior) with the expectation under the guess q, so the guess is punished for claiming causes that are impossible, and forgiven for missing causes that are possible; that is the “mode-seeking” behaviour in chapter 3’s Go deeper. Units: log here is the natural log, so the answer is in nats; divide by ln 2 = 0.693 to get bits (0.105 nats = 0.152 bits).

2. Two Gaussians: the bench’s default sliders

The bench starts at q = N(1, 0.6²) and p = N(0, 1). The closed form that chapter 4 will use, KL(N(μ, σ²) ‖ N(0, 1)) = ½(μ² + σ² − 1 − log σ²), gives

½ (1² + 0.36 − 1 − log 0.36) = ½ (0.36 + 1.022) = 0.691 nats

which is the first number the bench shows. The other direction uses the general formula between any two Gaussians, which the bench computes and which chapter 6’s learned priors need:

KL( N(μ₁, σ₁²) ‖ N(μ₂, σ₂²) ) = log(σ₂/σ₁) + (σ₁² + (μ₁ − μ₂)²) / (2σ₂²) − ½

With p first: log(0.6/1) + (1 + 1)/(2·0.36) − ½ = −0.511 + 2.778 − 0.5 = 1.767 nats, two and a half times the other direction. The wide p puts a lot of mass out near ±2, where the narrow q has almost none; that is the “a has mass, b does not” case, and it is expensive. A few more values worth having seen: KL(N(0, 0.6²) ‖ N(0, 1)) = 0.19 (narrower than the prior, same center); KL(N(0, 3²) ‖ N(0, 1)) = 2.90 (three times too wide).

How it is implemented: two ways to compute a KL

When both distributions have a known density, use the closed form. When you only have samples from q and the two log-densities, KL is an expectation (chapter 1’s definition) and is estimated by Monte Carlo. Both are shown; they agree to within the sampling error.

import numpy as np
from scipy.stats import norm
mu, s = 1.0, 0.6                                        # q = N(1, 0.6^2); p = N(0, 1)

kl_closed = 0.5 * (mu**2 + s**2 - 1 - np.log(s**2))     # 0.6908

z = norm.rvs(mu, s, size=10000, random_state=0)         # samples from q
# E_q[log q - log p] ~ 0.69 +- 0.01
kl_mc = np.mean(norm.logpdf(z, mu, s) - norm.logpdf(z, 0, 1))

# discrete case
q = np.array([0.5, 0.5, 0.0]); p = np.array([0.45, 0.45, 0.10])
mask = q > 0                                            # 0 * log 0 counts as 0
kl_qp = np.sum(q[mask] * np.log(q[mask] / p[mask]))     # 0.1054 nats
# torch: torch.distributions.kl_divergence(Normal(mu, s), Normal(0., 1.)) gives the closed form

One inequality: the log of an average beats the average of logs

The logarithm curve bends downward; it is concave. Take two numbers, 1.5 and 5. Their average is 3.25, and log 3.25 = 1.18. But the average of their logs is (log 1.5 + log 5) / 2 = 1.01, which is smaller. This always happens for a concave function: log(average) ≥ average(log), with equality only when all the numbers are the same. It is called Jensen's inequality, and it holds for averages weighted by any distribution, which is exactly how chapter 3 will use it.

1.5 5 average = 3.25 log(average) = 1.18, on the curve average of logs = 1.01, on the chord log x
The chord between any two points of the log curve lies below the curve. Averaging first and then taking the log lands on the curve; taking logs first and then averaging lands on the chord, lower down.
Worked example: Jensen with unequal weights, and when the gap closes

Chapter 3 uses the inequality with an average weighted by a distribution, so check that version. Put weight 0.8 on 1.5 and 0.2 on 5:

log(0.8·1.5 + 0.2·5) = log 2.2 = 0.788   ≥   0.8·log 1.5 + 0.2·log 5 = 0.324 + 0.322 = 0.646

The gap is 0.142. Now make the two numbers equal, 3 and 3: log 3 = 1.099 on both sides, gap 0. The gap depends on how much the numbers being averaged vary: a lot of variation, a big gap; no variation, no gap. Hold on to this sentence. In chapter 3 the numbers being averaged are the ratios p(x | z) p(z) / q(z | x) for different z, and the ELBO is tight exactly when those ratios are all the same, which happens exactly when q is proportional to p(x | z) p(z), which is the true posterior. That is the whole of line 6 of the derivation, seen from here.

What “learned” and “loss” mean here

A model has weights, random at first. For each training example you compute a single number that says how badly the model did, the loss; then you nudge every weight a little in the direction that shrinks it. That nudging is gradient descent, and the bookkeeping that finds the direction for every weight is called backpropagation. On this page the loss is almost always “minus the log-likelihood” or “minus a bound on it”: making the loss small means making the data probable.

Worked example: one gradient step on the sensor model

Loss = −(total log-likelihood) of the three readings 1.97, 2.04, 2.01 under N(μ, 0.05²), with μ the only weight. Differentiating −Σ log N(xi; μ, σ²) with respect to μ gives −Σ(xi − μ)/σ². Start at μ = 1.80, the bench’s starting slider:

d loss / dμ = −(0.17 + 0.24 + 0.21) / 0.0025 = −248

Negative gradient means the loss goes down when μ goes up, so gradient descent moves μ up: μ ← μ − lr · (−248). With learning rate 0.001 the new μ is 2.048, which overshoots the optimum 2.007; with 0.0005 it is 1.924, which undershoots. The gradient tells you the direction and, roughly, the urgency; the learning rate decides how far to trust it. Real training uses Adam, which rescales the step so this 248 does not blow anything up (crash course).

import torch
x = torch.tensor([1.97, 2.04, 2.01]); sigma = 0.05
mu = torch.tensor(1.80, requires_grad=True)             # the one weight, random-ish start

loss = -torch.distributions.Normal(mu, sigma).log_prob(x).sum()
loss.backward()                                         # backpropagation fills mu.grad
print(mu.grad)                                          # tensor(-248.)
with torch.no_grad():
    mu -= 0.0005 * mu.grad                              # one gradient-descent step -> 1.924

loss.backward() is backpropagation: it walks back from the loss through every operation that produced it and accumulates d loss / d weight into each weight’s .grad. For μ it recovers the −248 computed by hand. With a network of a million weights it does the same thing for each of them in one pass, which is the only reason the recipe scales.

Next: Hidden causes

2. Hidden causes: the latent variable

Look at the two hundred demonstrations again. Each one was shaped by a decision the demonstrator made and never wrote down: go left or go right, how wide a berth to give the cup, how much to hurry. The recorded trajectory x is the visible result; the decision is a hidden cause. In modeling, a hidden cause is called a latent variable and written z.

A latent variable model tells a two-step story about how each trajectory came to be. First, draw z from a simple fixed distribution, the prior p(z), usually a standard Gaussian. Second, feed z to a network, the decoder, which outputs the parameters of a distribution over trajectories, p(x | z); draw x from it. Different z values give different trajectories, and because the decoder is a flexible network, a one-dimensional z can already encode “left or right, and how wide”.

prior p(z) = N(0, 1) a sample z decoder p(x | z) goal start three z, three x
The story a latent variable model tells: pick a hidden cause at random, then let the decoder turn it into an observation. Everything the model knows about “what trajectories look like” lives in the decoder's weights.

One number, many trajectories

This is the decoder of the trajectory model from the opening demo, with the cup fixed in the middle. Slide the hidden number z and watch what it produces. The grey curve is the prior: how often each z would be drawn.

latent z = -1.50

One latent number, and the decoder has organized the space: negative values go one way around the cup, positive values the other, and the size of the number sets how wide the berth is. Nobody told it to use z this way. It found this arrangement because it was the arrangement that made the demonstrations most probable, which is what the next two chapters are about.

Worked example: the smallest latent variable model, every quantity computed

Shrink the problem until a calculator suffices. Let x be one number, the lateral position (in metres, cup at 0) where the arm passes the cup. Let the hidden cause z take two values, L and R, each with prior probability ½. Let the “decoder” be: p(x | L) = N(−0.8, 0.2²) and p(x | R) = N(+0.8, 0.2²). The two-step story generates a trajectory like this:

z = 'L' if rng.random() < 0.5 else 'R'        # step 1: draw the hidden cause from the prior
x = rng.normal({'L': -0.8, 'R': 0.8}[z], 0.2) # step 2: draw the observation given the cause

Now observe x = 0.9 (the arm passed on the right). Every named object on this page can be computed:

objectnamez = Lz = R
p(z)prior0.50.5
p(x = 0.9 | z)likelihood: how well each cause explains 0.94 × 10−161.760
p(x, z) = p(x | z) p(z)joint2 × 10−160.880
p(x = 0.9) = sum of the joint over zmarginal likelihood, the “impossible sum”0.880
p(z | x = 0.9) = joint / marginalposterior2 × 10−16≈ 1.000

Read the rows top to bottom and you have Bayes’ rule performed once: prior times likelihood, normalized by the marginal, gives the posterior. The posterior says what common sense says: an arm that passed at +0.9 almost certainly chose R. The likelihood row is where the needle appears: the cause L explains this observation 1016 times worse than R does, so it contributes nothing to the sum.

Now the observation that motivates the whole page. Score the average trajectory, x = 0 (straight through the cup):

p(x = 0) = ½·N(0; −0.8, 0.2²) + ½·N(0; 0.8, 0.2²) = ½·0.00067 + ½·0.00067 = 0.00067,   log p(0) = −7.31

against log p(0.9) = −0.13. The generative model finds the averaged trajectory about 1300 times less plausible than either demonstrated one. A squared-error regressor, asked for a single output at this cup position, would output exactly 0. That gap, seven nats, is the difference this page exists to explain.

With two values of z the “sum over all hidden causes” has two terms and is exact. This model is a Gaussian mixture, and chapter 2’s Explain in detail says the trouble only begins when z is continuous and the decoder is a network. The next box shows how quickly it begins.

The problem: training this model asks for an impossible sum

To train by maximum likelihood, we need log p(x) for each demonstration: how probable the model finds it. But the model only defines p(x | z) for a given z. To get p(x) we must average over every hidden cause that could have produced x:

p(x) =p(x | z) p(z) dz = Ep(z)[ p(x | z) ]

  • Read it as: “for every possible z, how well does it explain x, weighted by how likely that z is; add it all up.”
  • The integral sign is over all values of z. With one latent number you could approximate it on a grid. With 16 or 32 latent numbers, a grid has more points than there are atoms in the universe.

Worse, most of the sum is wasted. For a given trajectory x, almost every z makes the decoder produce something unlike x, so p(x | z) is essentially zero there. The whole sum comes from a tiny region of z values: a needle in a haystack. Sampling z from the prior and averaging (the Monte Carlo estimate from chapter 1) almost never hits the needle.

Worked example: averaging over the prior with a continuous latent, and watching it fail

Same observation, continuous cause. Let z ~ N(0, 1) and let the decoder be the fixed function f(z) = 1.2·tanh(z) with noise 0.25, so p(x | z) = N(x; 1.2·tanh z, 0.25²). (These are exactly the settings of the bench in chapter 3, so you can look at the curves there.) Observe x = 0.9. How well does each z explain it?

z−2−100.50.971.523
f(z)−1.16−0.910.000.560.901.091.161.19
p(0.9 | z)3×10−156×10−120.00250.611.601.210.940.80

The exact answer, by chopping the z axis into 1200 slices and summing p(0.9 | z) p(z) · Δz (numerical integration; code below), is p(0.9) = 0.416, so log p(0.9) = −0.878. Keep that number; chapter 3 will try to reach it from below.

Now the Monte Carlo way from chapter 1: draw ten z from the prior and average p(0.9 | z).

draw12345678910
z2.04−2.560.42−0.57−0.45−0.22−2.02−0.23−0.873.32
p(0.9 | z)0.9310−150.3710−810−710−510−1510−510−110.79

Average: 0.209, against the truth 0.416. Seven of ten draws contributed essentially zero; the whole estimate rests on three. The spread of a single-sample estimate here is 141% of the true value, so even a hundred draws leave a 14% error on p(x), and the error on log p(x), which is what training needs, is worse and biased (Jensen again: the log of a noisy unbiased estimate is biased low).

And this is the kind case. In this one-dimensional toy nearly half of the prior’s mass (all z > 0.12) explains the observation somewhat, because tanh saturates. Chapter 2’s Explain in detail does the honest arithmetic for a sharp needle in 16 dimensions: 0.01716 ≈ 10−29. There, ten draws, or ten billion, all give zero.

How it is implemented: computing the impossible sum when it is possible (1-D), and the trick that keeps it from underflowing

In one dimension the integral is a sum over a grid, which is what the chapter 3 bench does behind the scenes. The only subtlety is numerical: the terms are products of tiny densities, so you add them in log space with the log-sum-exp trick (subtract the largest, exponentiate, sum, add the largest back). Chapter Crash course explains it; here it is in use.

import numpy as np
from scipy.stats import norm
f = lambda z: 1.2 * np.tanh(z)
x0, sigma_x = 0.9, 0.25

z = np.linspace(-6, 6, 1201); dz = z[1] - z[0]                   # a grid over the latent
# log p(x|z) + log p(z), per grid point
log_joint = norm.logpdf(x0, f(z), sigma_x) + norm.logpdf(z)
m = log_joint.max()
log_px = m + np.log(np.sum(np.exp(log_joint - m)) * dz)           # log-sum-exp; -0.878
# p(z|x) on the grid; integrates to 1
posterior = np.exp(log_joint - log_px)

# the Monte Carlo attempt with prior samples
zs = np.random.default_rng(3).standard_normal(10)
print(np.mean(norm.pdf(x0, f(zs), sigma_x)))                     # 0.209, truth 0.416

With a 16-dimensional z a grid of 1200 points per dimension has 120016 ≈ 1049 cells. The grid method is a teaching tool. Chapter 3’s answer is to stop sampling from the prior and sample from something that already knows where the needle is.

The needle has a name. The posterior p(z | x) is the distribution over hidden causes given that we saw x: for a left-going trajectory, it sits on the negative z values that decode to that trajectory. By Bayes' rule it equals p(x | z) p(z) / p(x), and the denominator is the very quantity we cannot compute. So the posterior is out of reach too. Chapter 3 gets around both problems with one move.

prior p(z): wide, knows nothing about x how well each z explains this x: p(x | z) as a function of z posterior p(z | x) ∝ prior × explanation z
For one observed trajectory, only a narrow range of z explains it (ochre). The posterior (violet) is the prior reshaped by that explanation. Everything outside the bump contributes nothing to p(x), which is why blind averaging over the prior fails.

The simplest latent variable model

Let z take only two values, “left” and “right”, each with probability one half, and let p(x | z) be a Gaussian around the typical left or right trajectory. Then the integral is a sum of two terms and p(x) can be computed exactly. This is a mixture of Gaussians, and it can be trained without any of the machinery below. The trouble starts when z is continuous and the decoder is a neural network: no closed form exists for the average, and the space of z is too large to enumerate.

Why one sample from the prior is useless

Suppose a single trajectory is explained well only by z within ±0.05 of 1.3. Under the prior N(0, 1), the probability of drawing such a z is about 1.7%. With a 16-dimensional latent and a needle that narrow in every dimension, it is 0.01716, roughly one in 1028. A Monte Carlo estimate would need that many draws before it stopped being zero. The estimate is unbiased and useless.

Sample smarter: the seed of the next chapter

If you knew roughly where the needle was, you could draw z from a distribution concentrated there and correct for the fact that you cheated (this is called importance sampling). A network that looks at x and says “the needle for this trajectory is around z = 1.3, give or take 0.1” is exactly such a distribution. Chapter 3 names it q(z | x).

What the decoder outputs

For trajectories, the decoder usually outputs the mean of a Gaussian over the waypoints, with a fixed spread σ: p(x | z) = N(x; f(z), σ²I). Then log p(x | z) = −‖xf(z)‖² / 2σ² plus a constant: a squared error. For images made of pixels in [0, 1], a Bernoulli per pixel is common and gives a cross-entropy. The choice of this distribution decides what “reconstructing well” means.

Worked example: a decoder’s log-likelihood is a squared error plus a constant

A two-waypoint trajectory x = (0.1, 0.3); the decoder outputs f(z) = (0.0, 0.5); the fixed spread is σ = 0.1.

log p(x | z) = −‖xf(z)‖² / 2σ² + const = −(0.01 + 0.04) / 0.02 + 2·(−log 0.1 − ½ log 2π) = −2.5 + 2.77 = 0.27

The constant (2.77 here, one copy of −log σ − ½ log 2π = 1.38 per output dimension) does not depend on z or on any weight, so it has no gradient and training ignores it; this is why the lab in chapter 4 prints its ELBO as “+ const”. What training feels is −(squared error)/2σ², and the 1/2σ² = 50 in front is the exchange rate between reconstruction error and everything else in the loss. Chapter 5 turns on this number.

For a Bernoulli pixel with predicted probability = 0.9: if the pixel is on, log = −0.105; if it is off, log(1 − ) = log 0.1 = −2.30. Being confidently wrong costs 22 times more than being confidently right earns, which is the shape of every cross-entropy.

import torch, torch.nn.functional as F
x = torch.tensor([0.1, 0.3]); fz = torch.tensor([0.0, 0.5]); sigma = 0.1
logp = torch.distributions.Normal(fz, sigma).log_prob(x).sum()           # 0.267: the full thing
# -2.5: what has a gradient
logp_train = -((x - fz) ** 2).sum() / (2 * sigma ** 2)

pix = torch.tensor([1., 0.]); logits = torch.tensor([2.197, 2.197])      # sigmoid(2.197) = 0.9
# log0.9 + log0.1 = -2.41
logp_bern = -F.binary_cross_entropy_with_logits(logits, pix, reduction='sum')

Why a plain Gaussian prior is enough

Any distribution over x can be written as a deterministic transformation of Gaussian noise, in the same way that any single-variable distribution is the inverse cumulative function applied to a uniform number. So a Gaussian z plus a flexible enough decoder loses no generality in principle. In practice the decoder's capacity and the training procedure decide how much of that generality you actually get.

How many latent numbers

The dimension of z is a design choice. Too few and the model cannot represent the data's variety; too many and later problems (chapter 5) bite harder. Trajectory models often use 2 to 32; image models use hundreds. Unused dimensions tend to switch themselves off during training, which is a mixed blessing.

The latent is not unique

Rotating or flipping z, and adjusting the decoder to undo it, gives an identical p(x). So the “meaning” of a latent direction is not determined by the data; two training runs can produce mirror-image latent spaces. Anything you read into the axes of a latent space is a property of one particular run.

Three families

Latent variable models (this page's VAEs and GANs) generate from a hidden code. Autoregressive models generate one piece at a time, each conditioned on the previous pieces, and have exact likelihoods but no compact code. Diffusion models, which dominate current robot policy learning, generate by gradually denoising; they can be seen as latent variable models with a very long chain of latents and are trained with a bound closely related to the one in the next chapter.

Next: The ELBO

3. The ELBO: a bound you can compute, derived until you can say it

Chapter 2 ended with two things we cannot compute: the likelihood p(x) and the posterior p(z | x). I’ll handle both by introducing a helper distribution q(z | x), our best guess at the posterior: a network, called the encoder, that looks at a trajectory and returns a Gaussian over z centered where it thinks the hidden cause lies. Then, instead of the true log-likelihood, compute a quantity that is always less than or equal to it but computable: the evidence lower bound, or ELBO. Push the bound up and two good things happen at once: the model explains the data better, and the guess gets closer to the true posterior.

The derivation is six lines. The point of this chapter is that you can reproduce them, in words, without notes. Try to predict each line before pressing the button.

The derivation, one line at a time

Each line comes with the sentence you would say out loud to explain it. Notation: Eq[·] is the average over z drawn from the guess q(z | x).

Line 1. Write down the thing you want

log p(x) = log ∫ p(x | z) p(z) dz

Say: “The log-likelihood of a trajectory is the log of an average over all hidden causes: how well each cause explains the trajectory, weighted by how common that cause is.”

This is the definition of p(x) from chapter 2 with a log in front. Nothing has been done yet.

Line 2. Bring in the guess

= log ∫ q(z | x) · [ p(x | z) p(z) / q(z | x) ] dz = log Eq[ p(x | z) p(z) / q(z | x) ]

Say: “Multiply and divide the inside by any distribution q over z I like, one that is allowed to depend on x. The value has not changed, but the integral now reads as an average under my guess instead of under the prior.”

The only requirement: q must be nonzero wherever the posterior is, so that we never divide by zero where it matters. A Gaussian is nonzero everywhere, so this is automatic.

Line 3. Move the log inside

Eq[ log ( p(x | z) p(z) / q(z | x) ) ]

Say: “By Jensen's inequality, the log of an average is at least the average of the logs. Swapping them can only make the number smaller. So whatever comes next is a lower bound on the log-likelihood.”

This is the one inequality from chapter 1, with the average weighted by q. It is the only step that is not an equality, so it is the only place the truth gets lost.

Line 4. Split the log

= Eq[ log p(x | z) ] + Eq[ log p(z) − log q(z | x) ]

= Eq[ log p(x | z) ] KL( q(z | x) ‖ p(z) )

Say: “The log of a product is a sum of logs. The first piece is how well the decoder reconstructs the trajectory from a cause drawn from my guess. The second piece is, by definition, minus the KL divergence from my guess to the prior.”

Check the second piece against chapter 1: KL(qp) = Eq[log q − log p]. Here the signs are flipped, hence the minus.

Line 5. Name it

ELBO(x) = Eq(z|x)[ log p(x | z) ] KL( q(z | x) ‖ p(z) ) log p(x)

Say: “Reconstruction minus KL. That is the evidence lower bound; ‘evidence’ is another name for p(x). It is what we will maximize, because we can compute it and we cannot compute the real thing.”

Both terms are computable: the reconstruction term by drawing z from q and running the decoder, the KL term in closed form when q and the prior are Gaussians (chapter 4).

Line 6. Exactly how much was lost

log p(x) − ELBO(x) = KL( q(z | x) ‖ p(z | x) )

Say: “The gap between the bound and the truth is exactly the KL from my guess to the true posterior. A perfect guess closes the gap. So raising the bound by changing q is the same thing as improving the guess about the hidden causes.”

Three lines prove this; they are in Explain in detail below. The consequence matters more than the proof: maximizing the ELBO does two jobs at once, training the decoder and training the encoder to approximate the posterior.

Here is the whole thing in one breath, which is the version to memorize: the log-likelihood is the log of an average over hidden causes; rewrite the average under a guess; move the log inside, which can only lower the value; the result splits into a reconstruction term minus a KL to the prior; that is the ELBO, and it misses the truth by exactly the KL from the guess to the true posterior.

Worked example: the ELBO as numbers, on the toy from chapter 2

Same toy: x = 0.9, decoder f(z) = 1.2·tanh z with noise 0.25, prior N(0, 1). The truth, computed on a grid in chapter 2, is log p(x) = −0.878. Now pick guesses q(z | x) = N(μ, s²) by hand and compute the two terms of the ELBO (the reconstruction term by integrating q(z) log p(x | z) on the grid, the KL by the closed form). The bench below lets you reproduce each row with the sliders.

guess q(z | x)Eq[log p(x | z)]KL(q ‖ prior)ELBO = first − secondgap = truth − ELBOKL(q ‖ posterior), computed separately
N(−1, 0.8²), the wrong side−22.640.54−23.1822.3022.30
N(0, 1²), the prior itself−10.560.00−10.569.689.68
N(0.97, 0.5²), near the needle−0.360.79−1.150.270.27
N(0.97, 0.15²), too confident+0.411.88−1.470.590.59
N(1.03, 0.38²), the best Gaussian+0.111.08−0.960.080.08

Five things to read off the table, in the order the derivation says them.

  1. The bound holds. No row’s ELBO exceeds −0.878. You cannot beat it with the sliders either.
  2. The gap is exactly KL(q ‖ posterior). The last two columns were computed by different formulas (one from log p(x) minus the bound, one by integrating q log(q/posterior) on the grid) and agree to every digit. That is line 6.
  3. A guess equal to the prior scores KL = 0 and is still terrible, because its reconstruction term averages log p(x | z) over all the useless z values chapter 2 complained about. The KL term is not the goal; the sum is.
  4. Too confident costs KL. Narrowing the guess from 0.5 to 0.15 raised reconstruction from −0.36 to +0.41 but raised the KL from 0.79 to 1.88; the sum went down. The two forces in the next section are these two columns.
  5. The best Gaussian leaves a gap of 0.08. The true posterior (dark curve in the bench) is skewed, with a long tail to the right because tanh saturates; no Gaussian matches it exactly. This is the amortization-free version of the compromise every real encoder makes, and the second decoder in the bench makes it dramatic: two bumps, one Gaussian, a gap of about log 2.

In words, for the best row: “A cause drawn from my guess reconstructs the observation with average log-density +0.11; my guess is 1.08 nats more concentrated than the prior; so my bound is −0.96, and I am 0.08 nats short of the truth because my guess is not quite the posterior.”

Watch the bound close

A one-dimensional toy in which everything can be computed exactly by numerical integration, so the gap is visible. The observation is the lateral position where the arm passed the cup, x = 0.9. The decoder is a fixed function f(z) with noise: p(x | z) = N(x; f(z), 0.25²). The prior is N(0, 1). You control the guess q(z | x) = N(μ, s²), in violet. The dark curve is the true posterior, which in this toy can be computed and which a real model never sees.

decoder:
guess mean μ:
guess spread s:
log p(x), the truth
ELBO, your bound
gap = KL(q ‖ posterior)
reconstruction term
KL(q ‖ prior)

Two things to take from the toy. First, the ELBO is never above the truth, no matter how you set the sliders; the inequality is real. Second, the gap depends only on how well the guess matches the posterior, so “climbing the bound” with respect to the guess is literally inference: finding out which hidden causes explain the observation. With the second decoder the posterior has two bumps (two z values produce the same x), a single Gaussian guess cannot match it, and the best the bound can do is settle on one bump and leave a gap. Real encoders make the same compromise.

The two terms as two forces

The reconstruction term wants the encoder to give every trajectory its own precise, narrow spot in latent space, so the decoder can reproduce it exactly. The KL term wants every guess to look like the prior: wide, centered at zero, indistinguishable from the guesses for other trajectories. Training balances the two. The balance is what makes the latent space usable: codes are informative enough to reconstruct from, yet packed closely enough around the prior that a fresh z drawn from N(0, 1) lands somewhere the decoder has seen before. That second property is what lets the robot generate new trajectories at run time by sampling z from the prior.

Worked example: the two forces, one slider at a time

Fix the guess’s center at μ = 1.03 (the best value from the table above) and vary only its width s. Reconstruction wants s small; the KL wants s = 1.

width s1.000.700.480.300.150.05
reconstruction term−2.70−1.08−0.17+0.26+0.42+0.46
KL(q ‖ prior)0.530.630.881.281.943.03
ELBO−3.23−1.71−1.05−1.02−1.52−2.57

Reconstruction gains a lot from narrowing at first (−2.70 to −0.17) and then saturates: once the guess sits inside the needle there is nothing left to gain. The KL cost keeps rising, slowly at first and then without bound, because −log s² in the closed form grows as s → 0. The sum peaks around s ≈ 0.35. This is the balance the text describes, and for a trained encoder it is struck separately for every input x: precise where precision buys reconstruction, vague where it does not.

what reconstruction wants every trajectory gets its own narrow spot, but a fresh z from the prior lands between spots what the KL term wants every guess looks like the prior: sampling works, but the code says nothing about the trajectory
Three trajectories, three guesses (teal, ochre, blue). The ELBO sits between the two extremes: codes that are informative but organized around the prior.

The gap identity, proved

For any z, the product rule gives p(x, z) = p(z | x) p(x), so log p(x) = log p(x, z) − log p(z | x). The left side does not depend on z, so averaging both sides over z drawn from q changes nothing on the left:

log p(x)= Eq[log p(x, z)] − Eq[log p(z | x)] = Eq[log p(x, z) − log q(z | x)] + Eq[log q(z | x) − log p(z | x)]   (add and subtract log q) = ELBO(x) + KL(q(z | x) ‖ p(z | x)).

Since a KL is never negative, this also re-proves that the ELBO is a lower bound, without Jensen. The two derivations are the same fact seen from two sides.

What maximizing the ELBO does to each network

Write θ for the decoder's weights and φ for the encoder's. The ELBO depends on both. Raising it with respect to φ, with θ fixed, cannot change log pθ(x), so by the gap identity it must shrink KL(qφ ‖ posterior): the encoder learns to infer causes. This is called variational inference. Raising it with respect to θ raises a lower bound on log pθ(x), which is as close as we can get to maximum likelihood. Doing both by gradient ascent on the same objective is the VAE.

Amortization

Classical variational inference fits a separate q for every data point by optimization. A VAE instead trains one encoder network that maps any x to its q in a single forward pass. This is called amortized inference: the cost of inference is paid once, during training, rather than per example. The price is an amortization gap: a shared network cannot fit every point as well as a per-point optimization would.

Estimating the reconstruction term

Eq[log p(x | z)] is an expectation, so it is estimated by Monte Carlo: draw z from q(z | x), run the decoder, evaluate log p(x | z). One sample per data point per training step is standard. It is noisy, but the noise averages out over a batch and over steps, and the estimate is unbiased. Making this single draw differentiable is the subject of chapter 4.

Worked example: one sample from q is not like one sample from the prior

Take the too-confident guess q = N(0.97, 0.15²) from the table. Its exact reconstruction term is 0.414. Draw one ε, form z = 0.97 + 0.15·ε, decode, score. Five separate single draws:

ε+0.35+0.82+0.33−1.30+0.91
z = 0.97 + 0.15ε1.021.091.020.781.11
f(z)0.9250.9580.9240.7790.963
log p(0.9 | z), the one-sample estimate0.460.440.460.350.44

Every single draw lands within 0.07 of the exact 0.414, because the guess already sits on the needle; the average over 100,000 draws is 0.4141. Compare chapter 2, where ten draws from the prior produced values spanning fifteen orders of magnitude. The encoder’s job is precisely to make one sample enough.

The same five draws, used to check line 2 of the derivation

Line 2 says p(x) = Eq[ p(x | z) p(z) / q(z | x) ]. Using the best guess N(1.03, 0.38²) and five draws, the ratios are 0.347, 0.340, 0.319, 0.461, 0.411. Their average is 0.376, so the estimate of log p(x) is log 0.376 = −0.98 (truth −0.878; with a million draws it converges). The average of their logs is −0.99: lower, as Jensen demands, but only by 0.01, because the five ratios are nearly equal, which is the equality condition from chapter 1. That near-equality is the same fact as the 0.08 gap: a guess close to the posterior makes the ratio nearly constant, and Jensen loses almost nothing. Go deeper’s importance-weighted bound is this calculation with k draws averaged before the log.

What the terms look like in code

With a Gaussian decoder of fixed spread σ, log p(x | z) = −‖xf(z)‖² / 2σ² + constant, so the reconstruction term is a squared error divided by 2σ². The value of σ therefore sets how much reconstruction matters relative to the KL: halving σ quadruples the weight on reconstruction. This knob returns in chapter 5 under the name β. For the whole dataset the ELBO is summed over examples, and the loss that is minimized is its negative.

How it is implemented: the ELBO for one batch, in PyTorch

Everything in the derivation becomes six lines. The reconstruction term is estimated with one draw per example; the KL term is the closed form (chapter 4). Sum over data dimensions and latent dimensions, then average over the batch; the loss is the negative.

def elbo_loss(x, encoder, decoder, sigma_x=0.1, beta=1.0):
    # q(z|x) = N(mu, exp(logvar)); shapes [B, d_z]
    mu, logvar = encoder(x).chunk(2, dim=-1)
    # one reparameterized sample per example
    z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu)
    # parameters of p(x|z): here the mean
    x_hat = decoder(z)
    # log p(x|z) up to a constant; sum over data dims
    recon = -((x - x_hat) ** 2).sum(-1) / (2 * sigma_x ** 2)
    # KL(q(z|x) || N(0, I)); sum over latent dims
    kl = 0.5 * (mu ** 2 + logvar.exp() - 1 - logvar).sum(-1)
    elbo = recon - beta * kl                                     # one number per example
    # minimize the negative, averaged over the batch
    return -elbo.mean()

Two lines deserve a second look. sum(-1) on the reconstruction: a trajectory’s log-density is the sum over its coordinates, so use sum, not the default mean of F.mse_loss; getting this wrong silently changes σx by a factor of the data dimension and is a leading cause of the collapse in chapter 5 (worked out in the crash course). And randn_like inside the loss: the noise is drawn fresh every step, which is what makes the single-sample estimate unbiased over training.

Rate and distortion

Read the KL term as a rate: how many nats of information the code z carries about x beyond what the prior already says. Read the reconstruction term as a distortion. The ELBO is then “distortion plus rate”, the objective of a lossy compressor, and a β in front of the KL trades one against the other. This view explains a lot of chapter 5.

Why the guess is mode-seeking

The gap is KL(q ‖ posterior), with the expectation under q. That direction punishes q heavily for putting mass where the posterior has none, and only mildly for missing posterior mass. So when the posterior has several bumps, the best single Gaussian sits on one of them rather than spreading across all, as the toy showed. The other direction, KL(posterior ‖ q), would spread out instead; it is what maximum likelihood does when fitting q to samples.

Tighter bounds

Importance-weighted autoencoders average the ratio p(x, z) / q(z | x) over k samples before taking the log. Jensen then loses less, and the bound tightens toward log p(x) as k grows. It costs k decoder passes per example, and, curiously, a tighter bound gives the encoder a weaker training signal.

A name from physics

Minus the ELBO is called the variational free energy. The expectation–maximization algorithm for mixture models is coordinate ascent on the same bound: the E step sets q to the exact posterior (closing the gap), the M step raises the bound with respect to θ.

Next: The VAE

4. The VAE: gradients through a random draw

Now I’ll address one remaining mechanical problem. The ELBO's first term is an average over z drawn from the encoder's guess, and we estimate it with a single random draw. To train the encoder we need to know how that term changes when the encoder's weights change: its gradient. But a random draw is not a smooth function of anything. If the encoder nudges μ upward, which way does “a random number from N(μ, σ²)” move? Backpropagation, which works by following the chain of operations from the loss back to each weight, hits the draw and stops. The chain is broken.

The reparameterization trick fixes this with a change of bookkeeping. A number drawn from N(μ, σ²) is the same thing as μ + σ·ε where ε is drawn from N(0, 1). So draw ε first, before anything else, and treat it as one more input to the computation, like the data. Then z = μ + σ·ε is an ordinary formula: dz/dμ = 1 and dz/dσ = ε. The dice are rolled outside the machine and the results are fed in; inside the machine everything is differentiable.

before: a random draw inside the chain encoder μ, σ randomdraw of z z ~ N(μ, σ²) loss no gradient can pass through a random draw after: the noise is an input ε drawn first from N(0, 1); no parameters to learn encoder μ, σ z = μ + σ · ε loss the gradient flows all the way back: dz/dμ = 1, dz/dσ = ε
Same distribution over z, different wiring. On the right, the only random thing is ε, and nothing we want to train depends on how ε was drawn.
Worked example: a gradient through a random draw, with numbers

Let the encoder currently output μ = 0.5 and σ = 0.2, and suppose the loss downstream of z is simply L = z² (a stand-in for “run the decoder and score”). The exact expected loss is E[z²] = μ² + σ² = 0.29, and its exact gradients are d/dμ = 2μ = 1.0 and d/dσ = 2σ = 0.4. Now do what a VAE does: draw one ε, compute z = μ + σε, and backpropagate through the formula.

ε drawnz = 0.5 + 0.2εL = z²dL/dμ = 2z · dz/dμ = 2z · 1dL/dσ = 2z · dz/dσ = 2z · ε
−1.20.260.0680.52−0.62
+0.30.560.3141.12+0.34
+1.70.840.7061.68+2.86
average over 100,000 ε0.2901.0000.399

Each row is one training step’s gradient: noisy (the σ-gradient even changes sign) but centred on the truth, as the last row shows. Chain rule, twice: the loss depends on z, z depends on μ with slope 1 and on σ with slope ε. Without the trick there is no “z depends on μ” to differentiate: z would just be a number that came out of a random number generator.

mu = torch.tensor(0.5, requires_grad=True); sigma = torch.tensor(0.2, requires_grad=True)
# the dice, rolled outside the graph; no gradient flows into it
eps = torch.randn(())
z = mu + sigma * eps                      # differentiable in mu and sigma
loss = z ** 2
loss.backward()
print(mu.grad, sigma.grad)                # 2z, 2z*eps: one row of the table

# the same thing through the library: rsample() = reparameterized sample, keeps the gradient.
# .sample() would cut the gradient, silently
z = torch.distributions.Normal(mu, sigma).rsample()

Same dice, different settings

Twenty values of ε were drawn once and are kept fixed. The dots are z = μ + σ·ε for those twenty. Move the sliders: because ε is frozen, every dot moves smoothly and predictably, which is exactly what a gradient needs. Press the button to roll fresh ε and see the dots jump instead.

μ: 0.00
σ: 1.00
average of z² over the 20 dots
how much that average changes per unit of σ, same ε
exact answer for E[z²] = μ² + σ²: d/dσ = 2σ

The middle number is a gradient estimate computed by nudging σ with the noise held fixed. It differs from the exact answer only by the sampling error of twenty dots, and it responds smoothly as you move σ. Without the trick you would have to compare two independent batches of random draws, and their difference would be mostly noise.

The KL term in closed form

The other half of the ELBO needs no sampling at all. When the encoder outputs a Gaussian with independent dimensions, and the prior is the standard Gaussian, the KL divergence is a short formula, summed over latent dimensions:

KL( N(μ, σ²) ‖ N(0, 1) ) = ½ Σj ( μj² + σj² − 1 − log σj² )

  • j runs over the latent dimensions; each contributes separately.
  • μj² punishes a guess whose center drifts from 0. σj² − 1 − log σj² is zero at σ = 1 and positive otherwise: it punishes a guess that is narrower or wider than the prior. A very narrow guess (σ → 0) costs a lot, because −log σ² grows without bound.
  • The whole thing is 0 exactly when μ = 0 and σ = 1 in every dimension: when the guess is the prior.

Encoders output log σ² rather than σ, so that any real number is a valid output and σ = exp(½ log σ²) is automatically positive.

Worked example: the KL term for a two-dimensional latent

The encoder outputs, for one trajectory, μ = (0.5, −0.2) and log σ² = (−0.7, 0.1), so σ² = (0.497, 1.105). Dimension by dimension:

dimensionμj²σj²−log σj²½(μ² + σ² − 1 − log σ²)
10.250.497+0.70½(0.25 + 0.497 − 1 + 0.70) = 0.223
20.041.105−0.10½(0.04 + 1.105 − 1 − 0.10) = 0.023
total0.246 nats

Dimension 1 is doing work: its centre has moved off zero and it has narrowed, and it pays 0.22 nats for that information. Dimension 2 is almost the prior and pays almost nothing. Summed over the batch, and over training, this is the number the lab below reports as “KL term (per point)” and “KL per latent dimension”; a dimension that reports 0.00 for long is not being used.

The gradients of this term are as short as the term itself, and you will meet them in the code box below: ∂KL/∂μj = μj (pull the centre toward 0) and ∂KL/∂(log σj²) = ½(σj² − 1) (pull the spread toward 1, from either side).

mu = torch.tensor([0.5, -0.2]); logvar = torch.tensor([-0.7, 0.1])
kl_per_dim = 0.5 * (mu ** 2 + logvar.exp() - 1 - logvar)     # tensor([0.2233, 0.0226])
kl = kl_per_dim.sum()                                        # 0.2459

The complete recipe

Training a VAE, one step
  1. Take a batch of trajectories x.
  2. Encoder: x → μ, log σ² (one pair per latent dimension).
  3. Draw ε ~ N(0, I); set z = μ + σ · ε.
  4. Decoder: z, the parameters of p(x | z).
  5. Loss = reconstruction error of against x  +  KL(N(μ, σ²) ‖ N(0, I)). This is minus the ELBO, estimated with one draw.
  6. Backpropagate through both networks (the trick makes step 3 differentiable) and update.
Using it
  1. To generate: draw z ~ N(0, I), run the decoder. The encoder is not used.
  2. To reconstruct or compress: run the encoder, take μ, run the decoder.
  3. To interpolate between two demonstrations: encode both, move along the line between their μ values, decode along the way.
How it is implemented: the grasp-point VAE of the lab below, in 40 lines of PyTorch

This is the exact model the lab trains in your browser: 240 grasp points on a circle of radius 1 with noise 0.05, encoder 2 → 32 → (μ, log σ²) with a two-dimensional latent, decoder 2 → 32 → 2, tanh hidden units, Gaussian decoder with σx = 0.1, Adam at learning rate 0.02 on the full batch. Run it and compare your printed numbers with the lab’s.

import torch, torch.nn as nn, math
torch.manual_seed(7)

# --- data: where the gripper closed on the rim of a bowl, seen from above
N = 240
angle = torch.rand(N) * 2 * math.pi
radius = 1 + 0.05 * torch.randn(N)
X = torch.stack([radius * angle.cos(), radius * angle.sin()], dim=1)     # [240, 2]

# --- networks
# 4 outputs = mu (2) and logvar (2)
enc = nn.Sequential(nn.Linear(2, 32), nn.Tanh(), nn.Linear(32, 4))
dec = nn.Sequential(nn.Linear(2, 32), nn.Tanh(), nn.Linear(32, 2))      # z (2) -> mean of x (2)
opt = torch.optim.Adam(list(enc.parameters()) + list(dec.parameters()), lr=0.02)
sigma_x, beta = 0.1, 1.0

for step in range(1, 501):
    mu, logvar = enc(X).chunk(2, dim=-1)                                # step 2 of the recipe
    z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu)             # step 3
    x_hat = dec(z)                                                      # step 4
    # step 5: -log p(x|z) + const, per point
    rec = ((x_hat - X) ** 2).sum(-1) / (2 * sigma_x ** 2)
    # KL(q || N(0, I)), per point
    kl = 0.5 * (mu ** 2 + logvar.exp() - 1 - logvar).sum(-1)
    # = -ELBO (beta = 1), batch mean
    loss = (rec + beta * kl).mean()
    opt.zero_grad(); loss.backward(); opt.step()                        # step 6
    if step % 100 == 0:
        print(step, f"rec {rec.mean():.2f}  kl {kl.mean():.2f}  kl/dim {kl.mean()/2:.2f}")

# --- using it
with torch.no_grad():
    # generate: prior -> decoder
    samples = dec(torch.randn(200, 2))
    # ~0.85-0.9; the rim is 1.0
    print("sample radius", samples.norm(dim=1).mean().item())
    # compress: mu for each grasp point
    codes = enc(X)[:, :2]
What the page’s own code does for the same step, decoded

The lab has no autograd; its backward pass is written by hand, and reading it is the best way to see that the trick is only bookkeeping. After the decoder’s backward pass returns d loss / dz (called dZ), the encoder’s output gradients are set to

d loss / d mu     = dZ * dz/dmu     + beta * dKL/dmu
                  = dZ * 1          + beta * mu
d loss / d logvar = dZ * dz/dlogvar + beta * dKL/dlogvar
                  = dZ * eps * (sigma / 2) + beta * (sigma^2 - 1) / 2

which in the page’s JavaScript reads dd[k] = dZ + beta*mu and dd[2+k] = dZ*EPS*0.5*sd + beta*0.5*(exp(lv)-1). The factor ½σ in the second line is the chain rule through σ = exp(½ log σ²): dz/d(log σ²) = ε · dσ/d(log σ²) = ε · ½σ. The two KL gradients are the ones derived in the box above. Then enc.backward(dd) pushes these through the encoder’s layers and Adam steps both networks with the gradients scaled by 1/N, which is the batch mean. PyTorch does all of this when you call loss.backward(); the numbers are identical.

trajectory x encoderq(z | x) μ log σ² KL( N(μ, σ²) ‖ N(0, 1) ) pulls the guess toward the prior + ε N(0, 1) z μ + σ·ε decoderp(x | z) reconstruction reconstruction loss: how far is from x
One training pass. Two losses, two networks, one differentiable path from the reconstruction back to the encoder thanks to the ε input. At run time only the decoder is needed.

Train a VAE on grasp points

A robot has recorded 240 places where its gripper closed on the rim of a round bowl, seen from above: points on a circle of radius 1, with a little measurement noise. The task is to learn where good grasp points are so the robot can propose new ones. Below, a real VAE with a two-dimensional latent trains in your browser: encoder 2 → 32 → (μ, log σ²), decoder 2 → 32 → 2, Gaussian decoder with σx = 0.1, Adam. Each press runs full-batch steps of the recipe above.

weight β on the KL term
Data space: recorded grasp points (grey), samples from the trained model (violet)
Latent space: each point's guess μ, colored by where it sits on the rim; grey rings are the prior at 1σ and 2σ
steps taken0
reconstruction term (per point)
KL term (per point)
ELBO (per point, β = 1 accounting)
KL per latent dimension
radius of model samples (mean ± spread)

With β = 1, after a few hundred steps the violet samples land on the rim: the robot can propose grasp points it never saw, and they are sensible. The latent plot shows why it works. The rim has been laid out around the origin, inside the prior's rings, so that a fresh draw from N(0, I) lands on a code the decoder knows how to read. The colors go around in order: the encoder has learned the rim's geometry without being told about angles. The β menu is for chapter 5; leave it at 1 for now.

Worked example: reading the lab’s numbers

Below are the values a run of this exact model produced (a numpy port of the page’s code, same architecture and optimizer; your run will differ in the second decimal). “rec” is ‖x‖²/2σx² per point, so the noise floor, from the 0.05 radial jitter in the data, is 0.05²/(2·0.1²) ≈ 0.13.

βstepsrec per pointKL per pointKL per dimensionradius of prior samplesverdict
15001.023.281.65 / 1.630.85 ± 0.20healthy
120000.783.291.67 / 1.620.89 ± 0.17healthy, still improving
0.15000.126.013.01 / 3.000.81 ± 0.35reconstructs at the noise floor; codes spread outside the prior
45003.392.171.09 / 1.080.87 ± 0.17healthy but coarser
2050016.21.040.50 / 0.550.82 ± 0.19half collapsed
10050049.70.0010.00 / 0.000.06 ± 0.02collapsed

How to read the healthy row. The KL of 3.3 nats per point is the information the code carries about where on the rim the grasp is. A useful rule of thumb: eKL is roughly the number of distinguishable codes, and e3.3 ≈ 27, so the encoder is telling the decoder “which of about 27 sectors of the rim”, each about 13° wide, and the reconstruction error of about 1.0 is the cost of not saying more precisely than that. With β = 0.1 information is ten times cheaper, the code spends 6 nats (e6 ≈ 400 sectors) and reconstructs to the noise floor, but the codes now sit at |μ| ≈ 1.5 to 2.4, outside the prior’s rings, so prior samples land between them and the sample radius has the widest spread of all rows. The collapsed row is chapter 5, and its reconstruction value, 49.7, is not an accident: it is the average ‖x‖²/(2·0.01) of points at radius 1, the cost of always answering “the centre”.

What the gradient actually is

Write the reconstruction term as Eε[ log p(x | μ + σε) ]. Because the expectation is now over ε, whose distribution has no parameters, the gradient can move inside it: the gradient with respect to μ is Eε[ ∂ log p(x | z)/∂z ], and with respect to σ it is Eε[ ε · ∂ log p(x | z)/∂z ]. Both are ordinary backpropagated quantities evaluated at the sampled z. One draw gives an unbiased, low-variance estimate; that is the whole reason a VAE trains at all.

Where the KL formula comes from

Plug the two Gaussian densities into KL(qp) = Eq[log q − log p]. The log of a Gaussian is a quadratic; its expectation under another Gaussian involves only the means and variances (E[z] = μ, E[z²] = μ² + σ²), and the pieces collect into ½(μ² + σ² − 1 − log σ²) per dimension. Nothing needs to be sampled.

The decoder's spread and β are the same knob

With a Gaussian decoder of spread σx, the reconstruction term is ‖x‖²/2σx² plus a constant. Dividing the whole ELBO by that factor shows that changing σx is equivalent to multiplying the KL term by β = σx² (up to a constant scale on the learning rate). The lab exposes β directly. Many implementations that “just use MSE plus KL” are implicitly choosing σx² = ½, which is often far too large for well-scaled data and is a common reason a VAE seems to ignore its latent.

Reading the latent plot

Each grey dot is one training point's μ. If they fill the prior's rings evenly, prior samples decode well. If they cluster in a corner or stretch far outside 2σ, samples from the prior will land where the decoder has not been trained, and the violet points drift off the rim. If they all pile up at the origin, read chapter 5.

What a VAE is, in one sentence

An encoder and a decoder trained together to maximize the ELBO, with the encoder's random draw written as μ + σ·ε so that gradients pass through it, and the KL term computed in closed form.

The alternative that does work without the trick

The score-function estimator (REINFORCE) rewrites the gradient of Eq[f(z)] as Eq[f(z) · ∇ log q(z)]. It needs no differentiable path through z, so it works for discrete latents and for black-box f. Its variance, however, is enormous compared with the reparameterized estimate, because it learns only from the correlation between f and the direction of the draw, not from ∂f/∂z. Reinforcement learning lives with this variance; VAEs do not have to.

Discrete latents

“Which of six grasp types” is discrete, and μ + σε does not apply. The Gumbel-softmax trick relaxes a categorical draw into a differentiable soft choice with a temperature. VQ-VAE takes a different route: a discrete codebook with a straight-through gradient, and no KL term at all. Both are common in robot manipulation models that want a small set of skills.

Better guesses

A diagonal Gaussian cannot represent a bent or two-humped posterior. Normalizing flows and inverse autoregressive flows transform a Gaussian draw through a chain of invertible, differentiable steps, giving a richer q that is still reparameterizable. Hierarchical VAEs stack several latent layers, each with its own guess.

Judging a trained VAE

Report the ELBO on held-out data; it is a lower bound on the held-out log-likelihood. For a tighter number, importance-sample log p(x) with many draws from q. For a robot, the more useful test is downstream: do prior samples produce feasible motions, and how often?

Next: Posterior collapse

5. Posterior collapse: when the model stops using its latent

I’ll start with how this failure happens. There is a way to make the KL term exactly zero: have the encoder output μ = 0 and σ = 1 for every input. The guess is then the prior, for every trajectory, so the code z carries no information about which trajectory it came from. The decoder, receiving pure noise, learns to ignore z and output whatever single answer minimizes the reconstruction error on average. That answer is the mean. For the cup, it is the trajectory through the cup.

This failure is called posterior collapse: the approximate posterior collapses onto the prior. It is the VAE's characteristic disease, and for a robot it is exactly the failure this page opened with. A collapsed trajectory model is a regression model in disguise. A collapsed grasp model proposes the same grasp for every draw of z. A collapsed motion-prediction model predicts one future, the average one, for a pedestrian who could go either way.

Make it happen

Go back to the lab in chapter 4. Set β to 20 or 100, press Reset weights, then Train 500 steps. Watch three things at once: the KL per dimension goes to zero, the latent plot shrinks to a dot at the origin, and the violet samples gather at the center of the bowl, where there is nothing to grasp. Then set β back to 1 and keep training: the model recovers, slowly, because the pull toward the prior is no longer strong enough to hold it there.

Jump to the lab

healthy each grasp point has its ownsmall guess, spread over the prior decoder: varied points, on the rim collapsed every guess is the prior: z says nothing decoder: one point, the average,nothing to grasp
Left: informative codes, useful samples. Right: the guess equals the prior for every input, and the decoder can only output the mean of the data.

Why it happens

The objective allows it. Collapse is a legitimate solution of the ELBO. The KL term is at its minimum, zero, and the decoder is doing the best any z-blind model can do. If the decoder is powerful enough to model the data reasonably well on its own (an autoregressive decoder that predicts each waypoint from the previous ones, for instance), the reconstruction term does not suffer much from ignoring z, and every bit of information the code carries costs KL. The trade is not worth it, and the optimizer declines it.

The dynamics push toward it. At the start of training the encoder is random, so z is noise to the decoder. The decoder's fastest route to a lower loss is to ignore this noise, which it learns within a few hundred steps. Once ignored, the encoder receives no gradient rewarding informative codes, only the KL gradient pulling toward the prior. The two networks lock each other into the collapsed state; it is a local optimum that is easy to enter and hard to leave.

The balance can be mis-set. A large β, or equivalently a large decoder spread σx, makes the KL cost dominate and produces collapse even with a weak decoder. This is what the lab shows. It happens by accident more often than people expect, because “MSE plus KL” silently fixes σx, and the right value depends on the scale of the data.

How to tell. The KL term is near zero, overall or in most dimensions. Decoding two different z values gives the same output. Samples from the prior all look alike, and they look like the mean of the data. Reconstructions look like the mean too, rather than like their inputs. Any one of these is a red flag; together they are a diagnosis.
Worked example: the arithmetic that decides whether the lab collapses

Put numbers on the trade the optimizer is weighing, using the lab’s own values from chapter 4. Two candidate solutions:

  • Healthy: the code carries about 3.3 nats about the rim position and the decoder reconstructs to within about 1.0 nats of error. Loss per point = rec + β·KL = 1.0 + 3.3β.
  • Collapsed: the code carries nothing (KL = 0) and the decoder outputs the centre of the bowl for every z. Every grasp point is at radius ≈ 1, so rec = ‖x‖²/(2·0.1²) ≈ 1/0.02 ≈ 49.6 nats. Loss per point = 49.6, whatever β is.
βhealthy loss = 1.0 + 3.3βcollapsed lossthe optimizer prefers
14.349.6healthy, by 45 nats
414.249.6healthy
1550.549.6a toss-up (this is the break-even)
2067.049.6collapse
10033149.6collapse, decisively

The rule in one line: collapse is preferred when β × (KL the code needs) exceeds (reconstruction error the code saves). Here the break-even is β ≈ (49.6 − 1.0)/3.3 ≈ 15, which is why β = 20 in the lab only half-collapses (KL falls to 1.0, rec rises to 16) while β = 100 collapses completely within 100 steps. Since β and the decoder spread are the same knob (β = σx²/0.1² here), the break-even is also σx ≈ 0.39: tell the model the grasp noise is 0.39 on a bowl of radius 1 and it concludes, correctly given what it was told, that the latent is not worth its cost. The default “MSE plus KL” recipe asserts σx² = ½, i.e. σx = 0.71; on data of scale 1 that is on the wrong side of the line.

The arithmetic also says why the dynamics matter (second paragraph above). Early in training the decoder cannot yet use z, so the “reconstruction saved” is temporarily near zero, and for those first steps collapse is the better trade even at β = 1. Whether the model climbs out depends on whether the KL gradient has flattened the encoder’s outputs before the decoder learns to read them.

How it is implemented: the three remedies you will actually use, and the diagnostic
# 1. KL annealing (warm-up): beta climbs from 0 to 1 over the first T steps
def beta_at(step, T=5000):
    return min(1.0, step / T)

# 2. Free bits: per-dimension KL below a floor lam is not penalized
kl_dim = 0.5 * (mu ** 2 + logvar.exp() - 1 - logvar)       # [B, d_z]
# floor applied to the batch-average per dimension
kl_free = torch.clamp(kl_dim.mean(0), min=lam).sum()
# example: KL per dim (2.1, 0.03, 0.5, 0.0), lam = 0.1 -> penalized (2.1, 0.1, 0.5, 0.1):
# dims 2 and 4 pay the floor whether they carry information or not, so they may as well carry
# some.

# 3. beta < 1: just use beta = 0.1 .. 0.5 in elbo_loss; report the beta = 1 ELBO separately for
# honesty

# Diagnostic: active units. Dead dimensions have KL ~ 0 and mu that does not vary across the
# data.
with torch.no_grad():
    mu, logvar = enc(X).chunk(2, dim=-1)
    # per dimension, averaged over data
    kl_dim = (0.5 * (mu ** 2 + logvar.exp() - 1 - logvar)).mean(0)
    # both tests, per dimension
    active = (mu.var(0) > 0.01) & (kl_dim > 0.02)
    print(f"{active.sum().item()} of {mu.shape[1]} latent dimensions are active")
    # the two-z test: decode two different codes and look
    z1, z2 = torch.randn(1, mu.shape[1]), torch.randn(1, mu.shape[1])
    print((dec(z1) - dec(z2)).abs().max().item())                     # ~0 means z is ignored

Annealing is one multiplication per step. Free bits changes one line. Both cost nothing, so a trajectory model without at least one of them is unusual. The diagnostic belongs in your training log from day one: a KL per dimension printed every hundred steps would have caught every collapse this page describes.

The collapsed model, written out

At collapse, q(z | x) = p(z) and p(x | z) = p(x) for all z. The ELBO becomes Ep(z)[log p(x)] − 0 = log p(x): the bound is tight, and the model is exactly a z-free density model of x. If that density model is a fixed-spread Gaussian around the decoder's constant output, its best output is the data mean. If it is an autoregressive network, it can be quite good, which is why strong decoders collapse most readily: they have the least to lose.

What people do about it

  • KL annealing (warm-up). Start training with β = 0 and raise it to 1 over the first few thousand steps. The decoder learns to use z while it is free to, and by the time the KL cost arrives the codes are already informative. Simple and widely used.
  • Free bits. Do not penalize the KL in a dimension until it exceeds a floor, say 0.1 nats. Below the floor, information is free, so the model has no reason to give it up.
  • β < 1. Permanently down-weight the KL. The prior samples get somewhat worse (codes spread outside the prior) but collapse is avoided. Often the pragmatic choice for trajectory models.
  • Weaken the decoder. Dropout on the decoder's inputs, a smaller decoder, or, for sequence decoders, limiting how far back they can look. Forces the decoder to need z.
  • Skip connections from z. Feed z into every layer of the decoder rather than only the first, so it cannot be forgotten on the way through.
  • δ-VAE. Constrain the encoder's output so that KL(qp) is at least δ by construction, for example by bounding σ away from 1.
  • Aggressive encoder training. Update the encoder several times per decoder update early in training, so the guess tracks the posterior closely enough for the decoder to find z useful.

A collapsed conditional model is a regressor

In a conditional VAE (next chapter) the decoder also receives the situation c. If it ignores z, it is precisely the network “predict x from c” trained with squared error: the best single prediction from chapter 0. Posterior collapse turns the generative model back into the model whose failure motivated it.

Information preference

Chen et al. (2017) framed collapse as a preference built into the ELBO: information that can be modeled by the decoder locally, without z, will be, because routing it through z costs KL for no gain in the bound. What ends up in the latent is only what the decoder cannot capture on its own. This is a feature when you want a compact code of “global” properties, and a bug when you wanted the latent to carry the multimodal choice.

Optimization or objective?

For linear VAEs, Lucas et al. (2019) showed the ELBO's optimum matches probabilistic PCA and does not collapse; collapse in practice comes from optimization dynamics and from dimensions the model has good reason to switch off. So some “collapsed” dimensions are simply unused capacity, which is harmless. The problem is when all of them go, or when the dimension that should have encoded the discrete choice does.

Rate–distortion, again

Plot reconstruction (distortion) against KL (rate) for many β. The curve is the model's rate–distortion frontier. β = 1 picks one point on it; there is no law that this point is the one you want. For a robot that needs diversity, deliberately choosing a higher-rate point (β < 1) is a design decision, not a hack.

Measuring it

Count active units: dimensions whose KL, or whose covariance of μ across the dataset, is above a small threshold. Estimate the mutual information between x and z. Or simply decode a grid of z values and look.

Next: Conditional VAEs

6. Conditional VAEs: generating for the situation at hand

Next, I’ll make the model depend on the situation. A robot never needs “a trajectory”. It needs a trajectory for this cup position, a grasp for this object, a prediction of where this pedestrian goes next. The quantity it wants is a conditional distribution p(x | c), where c is whatever describes the situation: the cup's position, a point cloud of the object, the last second of a person's motion.

The conditional VAE (CVAE) is the VAE with c handed to both networks. The encoder looks at x and c and guesses z; the decoder produces x from z and c. The ELBO is unchanged except that c appears everywhere. Something useful happens to the meaning of z: since the decoder already knows the situation, the latent only has to carry what the situation does not determine. For the cup, that is the one thing the demonstrations disagreed about: left or right. The latent becomes the model's word for “the remaining choice”.

situation ccup position data xthe trajectory encoderq(z | x, c) μ, σ KL to N(0, 1) + ε z the situation also goes straight to the decoder decoderp(x | z, c) a trajectory for c reconstruction loss at run time: skip the encoder, draw z ~ N(0, 1), give the decoder c
The only change from chapter 4 is the ochre path: the situation is available to both networks. The latent is left to carry whatever the situation does not settle.

Trajectories around the cup, conditioned on where the cup is

The model from the opening demo with its controls exposed. It was trained offline with exactly the conditional ELBO below, on 400 demonstrations; its weights are embedded in this page. Encoder: 8 waypoints and the cup position → 48 hidden units → (μ, log σ²), one latent dimension. Decoder: (z, cup position) → 48 → 8 waypoints. In the demonstrations, the chance of going left grew smoothly as the cup moved right.

cup position c: 0.20
or set z by hand: 0.00
samples that hit the cup
demonstrations going left at this c

Sample a few times at different cup positions. The model has learned the demonstrators' habit: with the cup on the right, most samples go left, and the proportions track the data. The regression network, trained on the same demonstrations, goes through the cup whenever the two sides were about equally common. Now drag the z slider: one continuous number selects the side and the berth, exactly as in chapter 2, and it does so for every cup position, because the decoder reads the cup position separately.

You will also see a few red samples. They come from z values near the boundary between “left” and “right”, where the decoder has to interpolate between two behaviors and produces a trajectory that is neither. This is a genuine weakness of a continuous latent with a discrete choice inside it, discussed under Go deeper. In practice robots handle it the way the demo invites you to: draw several samples and let a collision check or a cost function discard the bad ones. Generative models propose; planners check.

Worked example: why squared error insists on the collision

Put the cup at c = 0, where the demonstrations split evenly. Look at one waypoint, the one beside the cup: in the left demonstrations its lateral position is −0.4, in the right ones +0.4. A regression network must output one number y for this c. Its expected squared error is ½(−0.4 − y)² + ½(0.4 − y)²:

prediction y−0.4 (commit to left)0 (the average)+0.4 (commit to right)
expected squared error½·0 + ½·0.64 = 0.32½·0.16 + ½·0.16 = 0.160.32

The average wins by a factor of two, and it goes through the cup. Nothing is wrong with the network or the optimizer; the loss asked for this. A generative model instead scores y = 0 by its density under the demonstrations, which chapter 2’s mixture example put at 1300 times below either side. Same data, opposite verdicts, because one loss asks “how close to the average” and the other asks “how plausible”.

The regression is also exactly what the CVAE turns into if its latent collapses (Explain in detail, below), which is why chapter 5’s diagnostic applies to policies: if every sample for a given c is identical, you have trained a regressor with extra steps.

How it is implemented: a CVAE, its training step, and its use at run time

Compared with chapter 4 there is one change: the situation c is concatenated to the input of both networks. The page’s embedded model has exactly these shapes: encoder (8 waypoints + 1 cup position = 9) → 48 → 2 (one μ, one log σ²: a one-dimensional latent), decoder (1 latent + 1 cup position = 2) → 48 → 8 waypoints. It was trained with the loss below on 400 demonstrations.

class CVAE(nn.Module):
    def __init__(self, d_x=8, d_c=1, d_z=1, h=48):
        super().__init__()
        # q(z | x, c)
        self.enc = nn.Sequential(nn.Linear(d_x + d_c, h), nn.Tanh(), nn.Linear(h, 2 * d_z))
        # p(x | z, c)
        self.dec = nn.Sequential(nn.Linear(d_z + d_c, h), nn.Tanh(), nn.Linear(h, d_x))
        self.d_z = d_z
    def loss(self, x, c, sigma_x=0.1, beta=1.0):
        mu, logvar = self.enc(torch.cat([x, c], -1)).chunk(2, -1)
        z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu)
        # the decoder sees c directly ...
        x_hat = self.dec(torch.cat([z, c], -1))
        # ... so z only has to carry what c does not fix
        rec = ((x - x_hat) ** 2).sum(-1) / (2 * sigma_x ** 2)
        kl = 0.5 * (mu ** 2 + logvar.exp() - 1 - logvar).sum(-1)
        return (rec + beta * kl).mean()
    @torch.no_grad()
    def sample(self, c, n=10, tau=1.0):                             # run time: no encoder, no x
        # tau < 1: typical; tau > 1: adventurous
        z = tau * torch.randn(n, self.d_z)
        # n candidate trajectories for this c
        return self.dec(torch.cat([z, c.expand(n, -1)], -1))

# deployment: propose, then check
cands = model.sample(cup_position, n=10)                            # [10, 8] waypoints
# your collision checker / cost function
ok = ~collides(cands, cup_position)
# none passed: draw 50 more and check again
best = cands[ok][0] if ok.any() else None

# what the model believes about the split, for a given cup position
many = model.sample(cup_position, n=1000)
# fraction passing the cup on the left
frac_left = (many[:, 3] < 0).float().mean()

Three notes. In sample, the temperature τ scales the prior: at τ = 1 a draw lands within ±0.5 of the centre 38% of the time, at τ = 0.5 it does so 68% of the time, so τ = 0.5 mostly reproduces the more common behaviour and rarely lands in the seam (Go deeper, below). The frac_left line is how the bench above computes “demonstrations going left at this c” and lets you check that the model’s proportions track the data’s. And the encoder must never see something at training time that will be missing at run time: if c is a ground-truth object pose in training and a camera estimate at deployment, the model was trained on the wrong problem.

A prior that depends on the situation

Some models replace N(0, 1) with p(z | c) = N(m(c), s(c)²), a small network that reads c. The KL term then needs the general two-Gaussian formula from chapter 1, per dimension: log(s/σ) + (σ² + (μ − m)²)/(2s²) − ½. For instance, an encoder guess N(0.5, 0.2²) against a conditional prior N(0.8, 0.5²) costs log(0.5/0.2) + (0.04 + 0.09)/(2·0.25) − ½ = 0.916 + 0.26 − 0.5 = 0.676 nats. At run time z is drawn from p(z | c) rather than N(0, 1), so a situation in which only one side is plausible can have a prior that already leans that way.

m, log_s2 = prior_net(c).chunk(2, -1)                                # conditional prior p(z|c)
kl = (0.5 * (log_s2 - logvar)
      + (logvar.exp() + (mu - m) ** 2) / (2 * log_s2.exp()) - 0.5).sum(-1)
# at run time: z = m + torch.exp(0.5 * log_s2) * torch.randn_like(m)

The conditional ELBO

log p(x | c) Eq(z|x,c)[ log p(x | z, c) ] KL( q(z | x, c) ‖ p(z | c) )

  • Every distribution is conditioned on c. The derivation of chapter 3 goes through line by line with c carried along; nothing new is needed.
  • p(z | c) is the prior. Most models use the fixed N(0, I), so it does not actually depend on c; some learn a small network that outputs a prior mean and spread from c, which lets the model say “in this situation, only one side is plausible”.
  • At run time: z ~ p(z | c), then x = decoder(z, c). Draw again for another answer.

What to put in c

Anything known at decision time: the robot's state, the goal, features of the scene from a perception network, the history of a pedestrian's motion. Two cautions. The encoder must not be able to read x back out of c, or z becomes pointless. And c should be what the robot will actually have at run time; a model conditioned on ground-truth object poses cannot be deployed with a camera.

Three uses in robotics

Trajectories and actions. Behavior cloning with a CVAE: c is the observation, x is the action or the next chunk of actions. Sampling at run time gives a policy that commits to one of the demonstrated behaviors instead of averaging them; the demo above is the smallest version of this.

Grasps. 6-DoF GraspNet (2019) is a CVAE whose c is a point cloud of the object and whose x is a 6-degree-of-freedom gripper pose. Sampling gives many candidate grasps; a separate evaluator network scores them and refines the good ones. Propose, then check.

Prediction. Models of where pedestrians and cars go next condition on their recent motion and the map, and use the latent for the discrete decision (turn, stop, continue) that the history does not reveal. Trajectron and its successors are built this way.

Regression is the collapsed CVAE

Remove z from the decoder and the model is a network from c to x trained with squared error. That is the “best single prediction” of the opening demo. A CVAE whose posterior collapses is this network with an unused input. So every remedy from chapter 5 is also a remedy for a multimodal policy that turns out to be unimodal.

The seam

After training, the encoder maps left demonstrations to one region of z and right ones to another. Between them lies a region where no training trajectory was ever encoded, but where the prior N(0, 1) still puts mass. Prior samples that land there are decoded by interpolation, and an interpolation between “left” and “right” is “through”. The mismatch between the prior and the aggregate posterior (the average of all the encoder's guesses) is a known VAE problem, and it is why roughly one sample in seven above hits the cup.

Fixes: a discrete latent for the discrete choice (Trajectron++ uses one), a mixture prior with several bumps, a learned prior fitted to the aggregate posterior, or a second small model trained on the codes. Or accept the seam and filter samples with a cost function, which most deployed systems do anyway.

Why diffusion policies took over

Diffusion models generate by iteratively denoising a random start toward the data, so their samples are pushed onto the data distribution by construction rather than decoded from a fixed prior; there is no seam to fall into and no posterior to collapse. Diffusion Policy (2023) and its descendants produce multimodal action chunks this way and are the current default for imitation learning in manipulation. They are trained with a bound that is a long-chained cousin of the ELBO, so nothing on this page is wasted.

Sampling temperature

Drawing z from N(0, τ²) with τ < 1 gives more typical, less varied samples; τ > 1 gives more variety and more misses. For a robot, τ is a knob between “do the common thing” and “explore”.

Next: GANs

7. GANs: learning to generate by playing a game

So far, I’ve described generators that ask “how probable is the data under this model?” and push a bound on that probability up. A generative adversarial network (GAN, 2014) never asks that question. It has no encoder, no likelihood and no ELBO. Instead it sets up a contest between two networks.

The generator G takes noise z and produces a sample: a grasp pose, a trajectory, a camera image. The discriminator D receives a sample and outputs the probability that it is real. D is trained to say “real” for recorded data and “fake” for the generator's output. G is trained to make D say “real”. Neither is told what a good sample looks like; that knowledge emerges from the contest, the same way a forger and an inspector sharpen each other.

Robots meet GANs in three places. Sim-to-real: a generator turns simulator images into images the discriminator cannot tell from the real camera, so a policy trained in simulation sees realistic inputs. Imitation: in GAIL, the discriminator tells expert behavior from the robot's, and its verdict is used as the reward the robot learns from. And data: generators propose grasps or trajectories from a small set of recordings.

znoise generator Gnoise → sample fake proposed grasp real xrecorded grasp discriminator Dreal or fake? D(x) = probability that x is real D learns: output 1 for real, 0 for fake G learns, through D, to make D output 1 for its fakes
Two networks, one contest. The generator never sees real data directly; everything it learns arrives as gradient through the discriminator.

The objective

minG maxD   Ex~data[ log D(x) ] + Ez~N(0,I)[ log ( 1 − D(G(z)) ) ]

  • The first term is large when D assigns high probability to real samples. The second is large when D assigns low probability to fakes. D wants both large: this is the log-likelihood of a classifier with labels “real” and “fake”.
  • G controls only the second term, through G(z), and wants it small: it wants D(G(z)) near 1.
  • “min max” means: D is chosen to make the expression as large as possible for the current G; G is chosen to make that best-case value as small as possible. In practice the two take turns with gradient steps, one or a few for D, then one for G.

What is G really minimizing? If D were perfect for the current generator, D(x) would equal pdata(x) / (pdata(x) + pG(x)): the fraction of samples near x that are real. Plug that in and the expression becomes a divergence between the real and generated distributions (the Jensen–Shannon divergence, a symmetric relative of the KL). So a GAN, like a VAE, is minimizing a mismatch between two distributions. The difference is that it never writes either density down; the discriminator estimates the mismatch from samples alone. That is why GANs can produce sharp, realistic images where VAEs produce blurry averages: nothing in the objective rewards hedging.

real data pdata generated pG D* ≈ 1: all real here D* = 0.5 where the two overlap D* ≈ 0: only fakes here the gradient G receives points from the violet bump toward the dark one 10
The best possible discriminator for a given generator. Its slope, where the two distributions overlap, is the signal that moves the generator's samples toward the data.
Worked example: the GAN objective with numbers
1. Scoring one real and one fake

D outputs 0.9 for a recorded grasp and 0.2 for a generated one. The objective’s value for this pair is log 0.9 + log(1 − 0.2) = −0.105 − 0.223 = −0.33. If D were guessing, 0.5 for everything, the value would be log 0.5 + log 0.5 = −1.386 = −log 4; if D were perfect, 1 for real and 0 for fake, it would be 0. So a discriminator’s score lives between −log 4 (useless) and 0 (perfect), and the −log 4 that appears in the divergence formula in Explain in detail is the guessing baseline. The lab plots exactly these values as “D loss” with the sign flipped, which is why the line marked “log 2 ≈ 0.69, D guessing” is drawn there: the per-sample loss of a guessing D is log 2.

2. The best possible D at one point

Near some grasp pose x, the real data has density 0.3 and the generator’s samples have density 0.1. Then D*(x) = 0.3 / (0.3 + 0.1) = 0.75: of every four samples that land here, three are real, and the best D says so. Where the generator produces nothing, D* = 1; where only the generator produces samples, D* = 0.

3. Mode collapse and hopping, in numbers

The box has four sides with a quarter of the real grasps each. Suppose the generator produces perfect eastern grasps and nothing else. On the east cluster, real density is ¼ of the data and generated density is all of the generator’s, so D*(east) = 0.25 / (0.25 + 1) = 0.2: the discriminator calls the generator’s flawless eastern grasps 80% fake, and on the other three sides D* = 1. The gradient the generator receives therefore points away from the east, toward wherever D says “real”, and the nearest such place is one of the other sides: that is mode hopping, and the figure below shows it. Nothing in this arithmetic rewards the generator for splitting itself four ways; covering two sides would lower the Jensen–Shannon divergence from 0.38 to 0.22 nats (maximum log 2 = 0.69), but the generator never sees that number, only D’s current slope.

4. Why the non-saturating loss

D writes its output as D = sigmoid(ℓ) for a logit ℓ, and what backpropagation hands the generator is d(loss)/dℓ. For a fake that D confidently rejects, D(G(z)) = 0.01:

generator lossd loss / dℓvalue at D = 0.01
original: log(1 − D)−D−0.01
non-saturating: −log D−(1 − D)−0.99

A hundred times more signal for the same fake, and largest exactly when the fake is worst. The lab implements these two derivatives literally: in its code the gradient handed to the generator is pg − 1 for the non-saturating loss and −pg for the original, where pg is D(G(z)). Switch the loss menu to “original minimax”, reset, and watch the first hundred steps: the generator barely moves while D is confident.

Train a GAN on approach directions

A box sits on a table and can be grasped from any of its four sides. The recorded grasp approach points (grey) form four clusters. A generator (2 → 32 → 32 → 2) and a discriminator (2 → 32 → 32 → 1) train in your browser with the objective above and Adam, on batches of 64 real and 64 generated points. The background shows what D currently believes: ochre where it says “real”, blue where it says “fake”, white where it cannot tell. Violet dots are 200 proposals from the generator.

learning rates
generator loss
Data space, with D’s belief as background
Share of proposals nearest each side
Losses over the last 300 steps (ochre D, violet G)
steps0
D loss
G loss
largest share on one side

With balanced settings, the four clusters are usually all covered within one to two thousand steps, though not always, and not permanently: keep training and you may see the coverage drift. Switch to the eager preset, reset, and train: the generator typically piles all of its proposals on one side, the discriminator learns that side is fake, and the proposals jump to another side. That is mode collapse, and the jumping is mode hopping. Watch the loss traces while it happens: they oscillate and tell you almost nothing about whether the samples are good.

Mode collapse

Mode collapse is the generator producing one kind of sample, or a few, for every z: all grasps from the east, all pedestrians turning left, all simulated images rendered with the same lighting. The objective allows it because the discriminator is only ever asked “is this sample real?”, never “has the generator covered everything?”. A generator that makes one perfectly realistic kind of output fools D as well as one that makes all kinds. It is often easier to make one kind well, so that is where gradient descent goes first.

Collapse rarely stays still. Once every fake approaches from the east, D learns that eastern approaches are suspicious, so G's easiest improvement is to move everything north. D follows. The generator chases a discriminator that chases the generator, and the pair can circle indefinitely. For a robot the consequence is a proposal model that is confident and narrow: it will offer only eastern grasps when the east side is blocked, and offer them with conviction.

step 1000 every proposal from the east step 1200 D has learned “east is fake”; G moves step 1400 every proposal from the north
Mode hopping. The generator satisfies the discriminator locally, the discriminator adapts, and the generator moves on rather than spreading out.

Instability: why the game is hard to keep still

Training a VAE is ordinary minimization: one loss, one landscape, downhill until it flattens. Training a GAN is not. Each player's landscape is shaped by the other player and moves every step, so there is no fixed quantity being minimized and no fixed “bottom” to reach. The equilibrium the game is looking for (a generator D cannot beat, and a D that is the best response to it) is a saddle, not a valley, and gradient steps taken by both players at once do not naturally settle into a saddle. They can circle it forever, or spiral away from it.

The simplest game that will not settle

One player controls x and wants x·y small; the other controls y and wants it large. The equilibrium is the origin. Each step, both players move along their own gradient. Watch what happens under three ways of taking the steps.

steps0
distance from equilibrium

Worked example: one step of each rule, by hand

The objective is x·y. The minimizer’s gradient is ∂(xy)/∂x = y, so it moves xx − lr·y; the maximizer’s gradient is x, so it moves yy + lr·x. Start at (1.2, 0.6), distance 1.342 from the origin, with lr = 0.2 as in the bench.

rulenew xnew ydistancefactor per step
both move at once1.2 − 0.2·0.6 = 1.080.6 + 0.2·1.2 = 0.841.368√(1 + lr²) = 1.0198, always > 1
take turns (y uses the new x)1.080.6 + 0.2·1.08 = 0.8161.354oscillates; orbit is a closed loop
extragradientlook ahead to (1.08, 0.84), then x = 1.2 − 0.2·0.84 = 1.032y = 0.6 + 0.2·1.08 = 0.8161.3160.981, always < 1

Simultaneous steps multiply the distance by 1.0198 every time, so after 25 steps it is 1.63 times larger, and after 250 steps 130 times: the outward spiral. Each step is perpendicular to the line to the origin (the gradient (y, −x) is at right angles to (x, y)), so a straight step of any length lands slightly farther out; only an infinitely small step would stay on the circle. Extragradient replaces the gradient at the current point with the gradient at the point one plain step ahead; that gradient has a small inward component, factor 0.981 here, and the spiral turns in. The mapping to a GAN: x stands for the generator’s weights, y for the discriminator’s, and x·y is the simplest possible minimax objective; Go deeper explains why real GANs behave like this toy near their equilibrium.

x, y, lr = 1.2, 0.6, 0.2
def simultaneous(x, y):  return x - lr * y,        y + lr * x
def alternating(x, y):   x2 = x - lr * y;          return x2, y + lr * x2
def extragradient(x, y):
    xh, yh = x - lr * y, y + lr * x                 # look ahead with a plain step
    return x - lr * yh, y + lr * xh                 # step using the gradients from there

Three more things make it worse in a real GAN. If the discriminator becomes too good, D(G(z)) is essentially 0 for every fake, log(1 − D) is flat there, and the generator receives no gradient at all; the original paper already recommended the non-saturating replacement −log D(G(z)), which keeps a gradient alive but does not remove the imbalance. When the real and generated distributions do not overlap, the Jensen–Shannon divergence is constant and its gradient says nothing about which way to move. And the losses themselves are useless as progress bars: D's loss going up may mean G is improving or that D has been outpaced, and there is no way to tell from the numbers alone.

The optimal discriminator, and what G then minimizes

For fixed G, maximize the objective pointwise: at each x, choose D(x) to maximize pdata(x) log D + pG(x) log(1 − D). Setting the derivative to zero gives D*(x) = pdata / (pdata + pG). Substituting back, the objective equals 2·JS(pdatapG) − log 4, where JS is the Jensen–Shannon divergence: the average of two KLs, each from one distribution to their mixture. It is zero only when pG = pdata. So with a perfect discriminator at every step, G would be doing divergence minimization. In practice D is never optimal, so what G actually minimizes is a moving approximation.

The training loop

One GAN step
  1. Sample a batch of real x and a batch of noise z; compute fakes G(z).
  2. Update D by gradient ascent on log D(x) + log(1 − D(G(z))), i.e. a binary classifier's log-likelihood. Sometimes several D steps per G step.
  3. Sample fresh z; update G by gradient descent on −log D(G(z)) (non-saturating) or on log(1 − D(G(z))) (original). D's weights are held fixed during this step; the gradient flows through D into G.
How it is implemented: the lab’s GAN in PyTorch

Same shapes and settings as the lab: G 2 → 32 → 32 → 2, D 2 → 32 → 32 → 1 (a logit), tanh hidden units, Adam with learning rate 0.001 and β₁ = 0.5 for both, batches of 64 real and 64 fake, non-saturating generator loss.

import torch, torch.nn as nn, torch.nn.functional as F
torch.manual_seed(3)
# four approach directions
centers = torch.tensor([[0.8, 0.], [0., 0.8], [-0.8, 0.], [0., -0.8]])
# a side at random, plus 0.1 noise
def real_batch(B):
    return centers[torch.randint(0, 4, (B,))] + 0.1 * torch.randn(B, 2)

G = nn.Sequential(nn.Linear(2, 32), nn.Tanh(), nn.Linear(32, 32), nn.Tanh(), nn.Linear(32, 2))
D = nn.Sequential(nn.Linear(2, 32), nn.Tanh(), nn.Linear(32, 32), nn.Tanh(), nn.Linear(32, 1))
optG = torch.optim.Adam(G.parameters(), lr=1e-3, betas=(0.5, 0.999))
optD = torch.optim.Adam(D.parameters(), lr=1e-3, betas=(0.5, 0.999))
B = 64; ones, zeros = torch.ones(B, 1), torch.zeros(B, 1)

for step in range(2000):
    # --- D step: a binary classifier, labels 1 = real, 0 = fake
    x_real = real_batch(B)
    x_fake = G(torch.randn(B, 2)).detach()          # detach: this step must not train G
    # lossD = -[ log D(x) + log(1 - D(G(z))) ], the classifier's cross-entropy
    lossD = (F.binary_cross_entropy_with_logits(D(x_real), ones)
             + F.binary_cross_entropy_with_logits(D(x_fake), zeros))
    optD.zero_grad(); lossD.backward(); optD.step()

    # --- G step: fresh noise, fool D. D's weights get gradients but are never stepped here.
    x_fake = G(torch.randn(B, 2))
    lossG = F.binary_cross_entropy_with_logits(D(x_fake), ones)   # non-saturating: -log D(G(z))
    # original minimax would be: lossG = -F.binary_cross_entropy_with_logits(D(x_fake), zeros)
    optG.zero_grad(); lossG.backward(); optG.step()

# coverage: nearest side for 200 proposals
with torch.no_grad():
    prop = G(torch.randn(200, 2))
    side = torch.cdist(prop, centers).argmin(1)
    # healthy: about 0.25 each; collapsed: one ~1.0
    print(torch.bincount(side, minlength=4) / 200.)

Why binary_cross_entropy_with_logits and not a sigmoid followed by log: the fused version computes log sigmoid(ℓ) stably for large negative ℓ, where sigmoid underflows to 0 and log 0 is −∞. Its derivative with respect to the logit is sigmoid(ℓ) − label, which is the line d.push(p − label) in the page’s hand-written D step. The detach() in the D step and the untouched optD in the G step are the two places a GAN implementation most often goes wrong; a G that is accidentally trained to help D, or a D that is nudged during the G step, produces an unstable run that looks like ordinary GAN instability and is not.

Why non-saturating helps

Early in training, fakes are obviously fake and D(G(z)) ≈ 0. The original loss log(1 − D) has gradient −1/(1 − D) ≈ −1 with respect to D, but D's own output is saturated at zero, so almost nothing reaches G. The non-saturating loss −log D has gradient −1/D, which is huge exactly when D is small: the worse the fake, the stronger the push. It changes the equilibrium's neighbourhood too: the generator is now minimizing something closer to a reverse KL, which is mode-seeking, and some argue this contributes to collapse.

Stabilizers, and what each one does

  • Adam with β₁ = 0.5 and small learning rates. Less momentum means the players overshoot each other less. The lab's balanced preset.
  • Two time-scale updates (TTUR). A slower generator lets D stay close to its best response, which is the condition under which G's gradient means something.
  • Label smoothing and instance noise. Train D against targets of 0.9 rather than 1, or add noise to both real and fake inputs, so the two distributions overlap and D cannot become perfectly confident.
  • Spectral normalization. Constrain each layer of D so that it cannot amplify its input by more than 1; D's output then changes smoothly with x and its gradients stay bounded. The single most widely used fix.
  • Wasserstein GAN. Replace the classifier with a critic that scores samples on an unbounded scale and is kept smooth (by a gradient penalty, WGAN-GP). The critic estimates the Earth mover's distance, which has a useful gradient even when the distributions do not overlap. Losses become meaningful again.
  • Minibatch discrimination and feature matching. Let D look at a whole batch of fakes, or ask G to match the average D-features of real batches, so that a generator producing one kind of sample can be caught.
  • Unrolled and extragradient updates. Let G anticipate D's next move before stepping, as in the toy above; the spiral turns inward.
Worked example: why the Wasserstein critic has a gradient when the classifier does not

Let all real grasps be at position 0 and all generated grasps at position θ (two point masses; a caricature of “the distributions do not overlap”). Then:

θ0.115
Jensen–Shannon divergencelog 2 = 0.6930.6930.693
Wasserstein-1 (earth mover’s) distance0.115

The JS divergence is the same for every θ ≠ 0: it only asks whether the two piles overlap, and they never do, so its gradient with respect to θ is zero and the generator gets no instruction about which way to move. The earth mover’s distance is the cost of carrying the generated pile onto the real one, |θ|, and its gradient is ±1: “move toward the data”, at every θ. That is the WGAN argument in one table. The catch is that a network estimating this distance must have bounded slope (be 1-Lipschitz), which is what the gradient penalty in WGAN-GP and spectral normalization enforce.

How it is implemented: spectral normalization is four lines

Divide each weight matrix of D by its largest singular value, estimated by one step of power iteration per training step (the weights change slowly, so the estimate tracks). The layer can then stretch its input by at most 1. PyTorch ships it as nn.utils.parametrizations.spectral_norm; here is what it does.

# W: [out, in]; u: running estimate of the top left-singular vector
def spectral_norm_step(W, u):
    v = F.normalize(W.t() @ u, dim=0)           # one power-iteration step
    u = F.normalize(W @ v, dim=0)
    sigma = u @ W @ v                           # ~ largest singular value of W
    # the normalized weight actually used in the forward pass
    return W / sigma, u

# the library version, applied per layer
SN = nn.utils.parametrizations.spectral_norm
D = nn.Sequential(SN(nn.Linear(2, 32)), nn.Tanh(),
                  SN(nn.Linear(32, 32)), nn.Tanh(), SN(nn.Linear(32, 1)))

The gradient penalty alternative adds λ·(‖∇xD()‖ − 1)² to D’s loss at points interpolated between real and fake samples, with λ = 10 in the original paper; it needs a second backward pass (through the gradient) each step, which spectral normalization avoids.

Conditioning, for robots

A conditional GAN gives both networks a condition c, exactly as the CVAE did. For sim-to-real, c is the simulated image and the fake is its realistic version; pix2pix needs paired images, CycleGAN removes that need by training two generators (sim→real, real→sim) with a consistency loss that the round trip must return the original. RL-CycleGAN adds a term that keeps the policy's value estimates unchanged by the translation, so realism is not bought at the price of changing what the image means for the task.

Convergence, in theory

Simultaneous gradient descent on minx maxy x·y has a Jacobian with purely imaginary eigenvalues, so it rotates around the equilibrium without approaching it, and any finite step size makes the rotation spiral outward; alternating updates conserve area and circle forever. Mescheder et al. (2018) showed that the same picture, with a single real data point and a generator with one parameter (the “Dirac-GAN”), reproduces every well-known GAN instability, and that gradient penalties on D, or consensus and extragradient methods, add the damping that makes the spiral turn inward.

The discriminator as a reward

Generative adversarial imitation learning (GAIL, 2016) treats the robot's policy as the generator: its state–action pairs are the fakes, the expert's are the real data, and D's estimate of “how expert-like is this” becomes the reward maximized by reinforcement learning. Adversarial motion priors (AMP) do the same for physically simulated characters and legged robots, producing natural-looking gaits from motion-capture clips. The GAN's instability comes along for the ride; these methods lean heavily on the stabilizers above.

Evaluating a generator

For images, the Fréchet inception distance compares feature statistics of real and generated sets and penalizes both bad quality and missing modes. For a robot, the honest metric is downstream: what fraction of proposed grasps succeed, how often a translated image leads the policy to the right action, how diverse the proposals are when diversity is needed.

Where GANs stand now

For images, diffusion models overtook GANs around 2021: they are stable to train, cover modes, and scale. Adversarial ideas persist where they are structurally useful: as learned rewards for imitation, as domain-adaptation losses that make features from simulation indistinguishable from real ones, and as sharpening terms added on top of likelihood-based models.

Next: Putting it together

Putting it together

I’ll bring the two families back together here. Both answer the same need: a robot facing several right answers should be able to produce any of them, not their average. A VAE reaches that by explaining the data with hidden causes, training a decoder and an encoder together on a bound on the log-likelihood that you can now derive in six lines. Its failure mode is to stop using the hidden cause. A GAN reaches it by a contest between a generator and a judge; its failure modes are to satisfy the judge with one kind of answer and to never stop circling.

VAE / CVAEGAN
Training signalA lower bound on log-likelihood: reconstruction minus KL.A discriminator's verdict; no likelihood anywhere.
NetworksEncoder and decoder; only the decoder at run time.Generator and discriminator; only the generator at run time.
OptimizationOrdinary minimization; stable.A two-player game; unstable without stabilizers.
Sample characterCovers the data; individual samples are averaged, often blurry.Sharp, realistic; may miss parts of the data.
Characteristic failurePosterior collapse: the latent is ignored, samples become the mean.Mode collapse and hopping; losses that say nothing.
Latent spaceOrganized around the prior by the KL term; interpolation works.Exists but is not regularized; structure is incidental.
Typical robotics useMultimodal action and trajectory generation, grasp proposals, motion prediction.Sim-to-real image translation, adversarial imitation (GAIL, AMP), data augmentation.
Knobs to knowβ or σx, KL annealing, free bits, latent size.Learning rates and Adam β₁, spectral norm, gradient penalty, D steps per G step.

The field's current default for multimodal robot actions, diffusion policies, borrows from both: a likelihood-style bound like the ELBO for training, and sample quality that GANs used to be needed for. If you meet one, you will recognize the parts.

The originals

  • Kingma and Welling, 2013. Auto-Encoding Variational Bayes. The VAE and the reparameterization trick.
  • Rezende, Mohamed and Wierstra, 2014. Stochastic Backpropagation and Approximate Inference in Deep Generative Models. The same idea, independently.
  • Sohn, Lee and Yan, 2015. Learning Structured Output Representation using Deep Conditional Generative Models. The CVAE.
  • Bowman et al., 2016. Generating Sentences from a Continuous Space. Posterior collapse named, and KL annealing.
  • Higgins et al., 2017. β-VAE. The β knob.
  • Goodfellow et al., 2014. Generative Adversarial Nets.
  • Arjovsky, Chintala and Bottou, 2017. Wasserstein GAN; Gulrajani et al., 2017, Improved Training of Wasserstein GANs.
  • Mescheder, Geiger and Nowozin, 2018. Which Training Methods for GANs do actually Converge?
  • Ho and Ermon, 2016. Generative Adversarial Imitation Learning.
  • Mousavian, Eppner and Fox, 2019. 6-DOF GraspNet. A CVAE for grasps.
  • Rao et al., 2020. RL-CycleGAN. Sim-to-real for robot grasping.
  • Chi et al., 2023. Diffusion Policy. Where the story goes next.

Next: Background and related work

Background and related work

I’ll close with two sets of supporting material. First, the ideas used throughout the discussion: what a nat is, what Adam does, what loss.backward() actually computes, why summing instead of averaging changes your model. Each entry is short and comes with numbers. Then I’ll connect the models above to related papers and methods: mixture density networks, VQ-VAE, ACT, World Models, Trajectron++, DAgger. I’m leaving a detailed treatment of diffusion and flow matching outside the scope of this article.

Part A. Supporting concepts

A1. Joint, marginal, conditional, and Bayes’ rule

Four objects, one relation. The joint p(x, z) is the probability of both together. Summing (or integrating) it over z gives the marginal p(x); dividing it by a marginal gives a conditional: p(z | x) = p(x, z) / p(x). Writing the joint two ways, p(x | z) p(z) = p(z | x) p(x), and solving for the posterior is Bayes’ rule:

p(z | x) = p(x | z) p(z) / p(x),   with   p(x) = Σz p(x | z) p(z)

Worked example: all four objects from one table

Chapter 2’s mixture, observation x = 0.9, causes L and R with prior ½ each, likelihoods 4×10−16 and 1.760. Joint: multiply, (2×10−16, 0.880). Marginal: add, 0.880. Posterior: divide, (2×10−16, 1.000). The denominator p(x) is what makes the posterior sum to one, and it is also the marginal likelihood the whole page is trying to compute: the two uses of one number are why chapter 2 says the posterior and the likelihood are out of reach together. Every “prior”, “posterior” and “evidence” on this page is one of these four objects with z as the cause and x as the observation.

A2. Entropy, cross-entropy, nats and bits

The entropy of a distribution, H(p) = −Σ p(x) log p(x), is its average surprise: how many nats it takes, on average, to say which outcome happened. The cross-entropy H(p, q) = −Σ p(x) log q(x) is the average surprise if the outcomes come from p but you were expecting q. KL is the difference: KL(pq) = H(p, q) − H(p), the extra surprise from expecting the wrong thing. A nat is the unit when the log is natural; divide by ln 2 = 0.693 for bits. The KL terms on this page are in nats because the code uses log, not log2.

Worked example

A fair left/right choice has entropy log 2 = 0.693 nats = 1 bit. The demonstrations p = (0.45, 0.45, 0.10) have entropy 0.949 nats. Expecting q = (0.5, 0.5, 0) when the truth is p gives cross-entropy −(0.45 log 0.5 + 0.45 log 0.5 + 0.10 log 0) = ∞, so KL(pq) = ∞, as in chapter 1; the other way round, H(q, p) = −(0.5 log 0.45 + 0.5 log 0.45) = 0.799, minus H(q) = 0.693, gives KL(qp) = 0.105 nats, the same number chapter 1 found directly.

The rule of thumb used in chapter 4, eKL ≈ number of distinguishable codes, comes from this: a uniform choice among k options has entropy log k, so a code that spends KL nats can be resolving about eKL equally likely alternatives. KL 3.3 nats → e3.3 ≈ 27 sectors of the rim; 3.3 nats is also 4.8 bits, and 24.8 is the same 27.

Every classifier you have trained by “cross-entropy loss” was minimizing H(labels, predictions), and the discriminator in chapter 7 is exactly such a classifier with labels real and fake; its loss is the cross-entropy, and a guessing D has loss log 2 per sample, the line drawn in the GAN lab.

A3. The Gaussian log-density, log σ², and numerical hygiene

Three habits every implementation on this page shares. Parameterize the spread as log σ² (or log σ): the network can output any real number, exp makes it positive, and the KL closed form wants log σ² anyway. Clamp it: logvar.clamp(-10, 10) keeps σ between e−5 = 0.007 and e5 = 148, because an early training step that sends log σ² to −50 gives σ = 10−11, a KL term of about 25 from −log σ² alone, and a gradient that overflows. Never take log of a probability that can be zero: use log_softmax, binary_cross_entropy_with_logits, Normal(...).log_prob, or add 10−9 as the page’s JavaScript does with Math.log(p+1e-9).

A4. The log-sum-exp trick

You need log(a₁ + … + ak) but only have the logs i = log ai, which are very negative. Exponentiating underflows to zero. Subtract the largest first:

log Σ ei = m + log Σ eim,   m = maxi i

Worked example

Logs −1000 and −1001. Naively: e−1000 + e−1001 = 0 + 0 in floating point, log 0 = −∞. With the trick: m = −1000, then −1000 + log(e0 + e−1) = −1000 + log 1.368 = −999.687. This is how chapter 2’s grid integral, the page’s m + Math.log(s*dz), mixture density networks (B1), the importance-weighted estimate of log p(x) (B10) and log_softmax all avoid −∞. In code: torch.logsumexp(l, dim), scipy.special.logsumexp.

A5. Backpropagation, in five lines

A loss is computed by a chain of operations. The chain rule says d loss / d(any intermediate) = d loss / d(next thing) × d(next thing) / d(this thing). Backpropagation is that rule applied backwards from the loss, once per operation, caching each local derivative; it costs about as much as the forward pass and gives the gradient with respect to every weight at once. loss.backward() in PyTorch builds this chain automatically from the operations you ran; the page’s MLP.prototype.backward is the same computation written by hand for one architecture (tanh layers: the local derivative of tanh is 1 − tanh², which is the 1-y*y in the code). The reparameterization trick is the chain rule with one extra link, z = μ + σε, whose local derivatives are 1 and ε; the numbers are in chapter 4. The one thing backpropagation cannot do is differentiate through a call to a random number generator, and every trick in this field (reparameterization, Gumbel-softmax, straight-through, REINFORCE) is a way to move the randomness to where it does not block the chain.

A6. Adam, and why GANs use β₁ = 0.5

Adam (Kingma and Ba, 2015) keeps two running averages per weight: m, of the gradient (a momentum), and v, of the squared gradient (a scale). The step is lr · / (√ + 10−8), where the hats correct for the averages starting at zero. Dividing by √ means each weight moves by about lr per step regardless of how large its gradient is, which is why the −248 in chapter 1 is harmless: Adam would step μ by about lr, not by lr × 248. β₁ is the memory of the momentum: 0.9 averages over roughly 10 steps, 0.5 over roughly 2. In a GAN the opponent changes the landscape every step, so a 10-step memory pushes you in a direction that stopped being right nine steps ago; β₁ = 0.5 forgets faster. That is the lab’s “balanced” preset. Learning rates of 10−3 to 10−4 are the usual range; the lab uses 0.02 for the VAE only because the problem is tiny.

Worked example: one Adam step

First step, gradient g = 0.5, defaults β₁ = 0.9, β₂ = 0.999. m = 0.1·0.5 = 0.05, v = 0.001·0.25 = 0.00025. Bias correction: = 0.05/0.1 = 0.5, = 0.00025/0.001 = 0.25. Step = lr · 0.5/√0.25 = lr · 1.0. Redo it with g = 0.005 and the step is again lr · 1.0. The gradient’s size sets nothing; only its sign and its consistency over steps do.

A7. Minibatches are Monte Carlo too

The loss you want is the average over the whole dataset; the gradient you compute is the average over 64 examples. That is a Monte Carlo estimate (chapter 1) of the full gradient: unbiased, with error shrinking like 1/√(batch size). Stochastic gradient descent works because unbiased noise averages out over steps while the signal accumulates. The VAE adds a second source of the same kind of noise, the single draw of z per example, and it works for the same reason. If you ever wonder whether one sample of z is enough, ask whether one batch of 64 is enough; it is the same question, and the answer is the same.

A8. Sums versus means, and data scale: the most common VAE bug

A trajectory’s log-likelihood is a sum over its coordinates. F.mse_loss defaults to the mean over all elements. Using the mean divides the reconstruction term by the data dimension D, which is equivalent to multiplying the KL term by D: a silent β = D.

Worked example

Eight waypoints in two coordinates, D = 16. The squared error on one trajectory is 0.32. “MSE + KL” with the sum: reconstruction term 0.32, which corresponds to σx² = ½ (since ‖·‖²/2σx² = ‖·‖² when σx² = ½). With the mean: 0.32/16 = 0.02, and now the KL, unchanged, is 16 times as important as before, so the effective β is 16 and the effective σx² is 8, i.e. σx = 2.8 on waypoints whose whole range is about 1. Chapter 5’s break-even for the lab was σx ≈ 0.39; this is seven times past it. The model collapses and the loss curve looks perfectly healthy.

The fix is two habits: reduction='sum' over data dimensions and mean over the batch only (as in every code box above), and standardizing the data (subtract the mean, divide by the standard deviation, per coordinate) so that “σx = 0.1” means the same thing on every dataset. Standardize c too, and undo the scaling on the decoder’s output at run time.

A9. Unbiased is not the same as accurate

Two estimators of the same expectation can both be unbiased and differ enormously in variance: chapter 2’s prior-sampling estimate of p(x) and chapter 3’s q-sampling estimate are both unbiased, and one is useless. The whole design of the VAE, and of the reparameterization trick over REINFORCE (chapter 4, Go deeper), is a search for low-variance unbiased estimators. When you build one, report its standard error from the samples themselves (f.std()/sqrt(n), chapter 1’s bench), and when you see a training curve, remember that it is one noisy sample path of the quantity you care about.

Part B. Related methods and papers

B1. Mixture density networks: the other classic answer to “several right answers”

Bishop, 1994. Mixture Density Networks. In robot learning: Mandlekar et al., 2021, What Matters in Learning from Offline Human Demonstrations for Robot Manipulation (robomimic), where a recurrent behaviour-cloning policy with a Gaussian-mixture output head was the strongest imitation baseline before diffusion policies.

Instead of a hidden z, let the network output the parameters of a mixture of K Gaussians directly: K weights πk(c) (through a softmax), K means μk(c), K spreads σk(c). The likelihood of an action is Σk πk N(x; μk, σk²), the loss is its negative log, computed with log-sum-exp (A4), and there is no ELBO because the sum over the hidden “which component” is over K terms and is done exactly. This is chapter 2’s “simplest latent variable model” with a network choosing the components per situation. To act, pick a component by its weight and sample from it, or take the mean of the heaviest component.

Worked example

For the cup at c = 0 the network outputs π = (0.5, 0.5), μ = (−0.8, +0.8), σ = (0.2, 0.2). A demonstration at x = 0.9: log-components log πk + log N(x; μk, σk) = (−36.13, −0.13); log-sum-exp gives log p(0.9) = −0.13, and the “responsibilities” ecomponent − total = (0.00, 1.00) say the right component explains it. The averaged trajectory x = 0: log-components (−8.00, −8.00), log p(0) = −7.31, exactly chapter 2’s number. Minimizing the negative log-likelihood therefore keeps the two means apart, where squared error (chapter 6) would merge them at 0.

class MDNHead(nn.Module):
    def __init__(self, n_in, d_x, K):
        super().__init__(); self.K, self.d_x = K, d_x
        self.net = nn.Sequential(nn.Linear(n_in, 64), nn.Tanh(), nn.Linear(64, K + 2 * K * d_x))
    def forward(self, c):
        out = self.net(c)
        log_pi = F.log_softmax(out[:, :self.K], -1)                             # [B, K]
        # [B, K, d_x] each
        mu, log_sigma = out[:, self.K:].view(-1, 2, self.K, self.d_x).unbind(1)
        return log_pi, mu, log_sigma.clamp(-7, 2)
    def nll(self, c, x):
        log_pi, mu, log_sigma = self(c)
        # [B, K]
        comp = torch.distributions.Normal(mu, log_sigma.exp()).log_prob(x[:, None, :]).sum(-1)
        # log-sum-exp over components
        return -torch.logsumexp(log_pi + comp, dim=-1).mean()
    @torch.no_grad()
    def sample(self, c):
        log_pi, mu, log_sigma = self(c)
        # which behaviour
        k = torch.distributions.Categorical(logits=log_pi).sample()
        rows = torch.arange(len(c))
        # mu_k + sigma_k * eps
        return mu[rows, k] + log_sigma.exp()[rows, k] * torch.randn_like(mu[:, 0])

When to prefer it over a CVAE. Low-dimensional actions with a handful of clearly separate behaviours, when you want the likelihood exactly and want to read off “how likely is left” as a number πk. Its failure modes: components collapsing onto each other (the mixture equivalent of chapter 5, usually fixed with a floor on σ and a few more components than you think you need), and poor scaling to high-dimensional outputs like action chunks or images, where a diagonal Gaussian per component is a bad shape. The CVAE’s latent is a continuous, shareable version of “which component”.

B2. Two more answers: discretize the actions, or score them

Shafiullah et al., 2022. Behavior Transformers: Cloning k modes with one stone. Florence et al., 2021. Implicit Behavioral Cloning.

Behavior Transformer (BeT): run k-means on the demonstrated actions to get k centroids, then train a transformer to (a) classify which centroid the next action is nearest to, with a cross-entropy loss, and (b) regress a small residual offset from that centroid. Multimodality lives in the classifier’s softmax; the regression only has to be unimodal within a bin. It is a mixture density network with the means fixed in advance by clustering. Implicit BC: instead of a network that outputs an action, train a network E(c, x) that scores an action, with a contrastive (InfoNCE) loss that ranks the demonstrated action above random alternatives; at run time, sample many candidate actions and pick the best-scoring one (or run a few gradient steps on x). An energy landscape can have several low valleys, so several right answers are natural; the cost is an optimization at every control step. Diffusion Policy was motivated as a fix for the difficulty of training these energy models.

B3. VQ-VAE: a discrete codebook instead of a Gaussian

van den Oord, Vinyals and Kavukcuoglu, 2017. Neural Discrete Representation Learning. In robotics: Lee et al., 2024, Behavior Generation with Latent Actions (VQ-BeT), which tokenizes action chunks with a residual VQ-VAE and then models the tokens.

The encoder outputs a vector ze; it is replaced by the nearest of K learned codebook vectors ek, and the decoder sees that. The latent is then one of K symbols: a skill index, a grasp type. There is no KL term (the prior over symbols is uniform, and the “posterior” is a point mass, so the KL is the constant log K), hence no posterior collapse in chapter 5’s sense; the corresponding disease is codebook collapse, where most of the K codes are never selected. Two problems and their fixes: nearest-neighbour selection has no gradient, so the decoder’s gradient is copied straight through to ze as if the replacement had not happened (the straight-through estimator); and the codebook must learn, so two extra squared-error terms pull codes toward the encoder outputs and the encoder outputs toward the codes.

loss = −log p(x | zq) + ‖ sg[ze] − ek ‖² + β ‖ ze − sg[ek] ‖²,   β = 0.25,   sg = stop-gradient

How it is implemented
class VQ(nn.Module):
    def __init__(self, K=64, d=8, beta=0.25):
        super().__init__(); self.codebook = nn.Embedding(K, d); self.beta = beta
        nn.init.uniform_(self.codebook.weight, -1 / K, 1 / K)
    # z_e: [B, d] from the encoder
    def forward(self, z_e):
        # [B, K] distances to every code
        dist = torch.cdist(z_e, self.codebook.weight)
        # nearest code index (no gradient)
        k = dist.argmin(-1)
        # the chosen codes, [B, d]
        e = self.codebook(k)
        # move codes toward encoder outputs
        codebook_loss = ((z_e.detach() - e) ** 2).sum(-1).mean()
        # move encoder outputs toward codes
        commit_loss = ((z_e - e.detach()) ** 2).sum(-1).mean()
        # value = e; gradient = as if z_q were z_e
        z_q = z_e + (e - z_e).detach()
        return z_q, k, codebook_loss + self.beta * commit_loss
# usage: z_q, k, vq_loss = vq(enc(x)); loss = recon_nll(dec(z_q), x) + vq_loss
# codebook health: torch.bincount(k, minlength=K) -- codes with zero counts for many steps are
# dead

The z_e + (e − z_e).detach() line is the entire straight-through trick: its value is e, its gradient is the identity. Codebook collapse is usually handled by re-initializing dead codes to random encoder outputs, or by the exponential-moving-average codebook update from the paper’s appendix. The Gumbel-softmax route (B9) is the differentiable alternative for the same discrete choice.

B4. ACT: the CVAE you will actually meet in a robotics paper

Zhao, Kumar, Finn and Abbeel, 2023. Learning Fine-Grained Bimanual Manipulation with Low-Cost Hardware (ALOHA / Action Chunking with Transformers).

ACT is chapter 6 built at scale, and reading its design choices with this page in hand is worth an afternoon. c is four camera images plus the 14 current joint positions; x is a chunk of the next k = 100 actions (two seconds at 50 Hz); the latent z, which the paper calls the style variable, is a 32-dimensional Gaussian. The encoder q(z | x, c) is a small transformer that reads the joint positions and the whole action chunk (not the images); the decoder p(x | z, c) is a ResNet-18 per camera feeding a transformer encoder–decoder that emits the 100 actions. Four choices to notice:

  • The reconstruction loss is L1, not squared error, with the KL weighted by β = 10. An L1 loss is the log-likelihood of a Laplace decoder rather than a Gaussian one (chapter 2’s “what the decoder outputs”); it is less dominated by the occasional large error, which matters for fine manipulation. The β of 10 is chapter 5’s knob; the authors report it mattered.
  • Action chunking: predicting 100 actions at once turns the multimodal choice into something made once per chunk rather than re-decided every 20 ms, and shortens the effective horizon over which errors compound (B7).
  • Temporal ensembling: a new chunk is predicted every step, so each future time is covered by many overlapping predictions; they are averaged with weights wi = em·i, m = 0.01, older predictions weighted higher, which smooths the motion without the pause a naive chunk-then-execute scheme has.
  • At run time z is set to 0, the prior mean, not sampled. The CVAE machinery is used to make training tolerate multimodal demonstrations (the latent absorbs the demonstrator’s variability so the decoder is not forced to average), and then deployment deliberately picks the single most typical style. That is chapter 6’s temperature τ turned all the way down to 0, and it is a legitimate design decision when the task needs consistency more than variety.
How it is implemented, in outline
# shapes: images [B, 4, 3, 480, 640], qpos [B, 14], actions [B, 100, 14]
# transformer over [CLS, qpos, a_1..a_100] -> [B, 32] each
mu, logvar = style_encoder(qpos, actions)
z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu)   # training: sample
# ResNet features + transformer -> [B, 100, 14]
a_hat = policy_decoder(images, qpos, z)
# Laplace decoder: L1, not squared error
l1 = F.l1_loss(a_hat, actions, reduction='none').sum(-1).mean()
kl = (0.5 * (mu ** 2 + logvar.exp() - 1 - logvar)).sum(-1).mean()
loss = l1 + 10.0 * kl                                              # beta = 10

# deployment, every control step t:
# z = prior mean, deterministic
a_hat = policy_decoder(images, qpos, z=torch.zeros(1, 32))
for i in range(100):
    # the prediction for time t+i made at time t
    buffer[t + i].append(a_hat[0, i])
# temporal ensemble over predictions for time t; oldest first
w = torch.exp(-0.01 * torch.arange(len(buffer[t])))
action_t = (w[:, None] * torch.stack(buffer[t])).sum(0) / w.sum()

B5. World Models: a VAE and a mixture density network, doing control together

Ha and Schmidhuber, 2018. World Models. Descendants: Hafner et al., 2019 (PlaNet) and 2020 onward (Dreamer), which replace the pieces with a recurrent latent state trained by a sequence ELBO.

Three parts. V, a VAE that compresses each camera frame to a 32-dimensional z (chapter 4, with a convolutional encoder and decoder and pixels as Bernoulli or Gaussian outputs). M, an RNN with a mixture-density output head (B1, five components) that predicts the next z from the current z, the action and its hidden state: a stochastic model of the future in latent space, multimodal because the future is. C, a tiny linear controller that maps (z, hidden state) to an action, trained by evolution strategies. The remarkable step is training C inside M’s imagination, then transferring to the real environment. For this page the lesson is structural: the VAE’s job was compression, the mixture’s job was multimodal prediction, and each was used exactly where its strengths lay. PlaNet and Dreamer merge V and M into one latent dynamics model trained with an ELBO over whole sequences (reconstruction of every frame, KL between the posterior over each latent state and a learned prior that depends on the previous state and action), and Dreamer’s “KL balancing” and free-bits floor are chapter 5’s remedies applied to that sequence model.

B6. Trajectron++: a discrete conditional latent, and how multimodal predictors are scored

Salzmann, Ivanovic, Chakravarty and Pavone, 2020. Trajectron++: Dynamically-Feasible Trajectory Forecasting With Heterogeneous Data. Before it, Ivanovic and Pavone, 2019 (Trajectron), and Lee et al., 2017 (DESIRE), the first widely used CVAE for trajectory forecasting.

Chapter 6’s Go deeper mentions the discrete latent; here is the shape of it. z is categorical with 25 values. The encoder outputs a distribution over those 25 from the agent’s history and its future (training only); a separate network outputs the conditional prior p(z | c) from the history alone, and the KL is between two 25-way discrete distributions (a 25-term sum, exact, no closed form needed). The decoder is a GRU that emits, per time step, a mixture of Gaussians over accelerations, which are integrated through a vehicle or pedestrian dynamics model so that every sample is a physically feasible path. Every device from this page appears: a conditional prior, a discrete latent for the discrete choice, a mixture output head, and a KL between the encoder’s distribution and that prior.

How you score a model like this. A multimodal predictor should not be judged on one sample against the one future that happened. The standard metrics draw K samples and report the error of the best one: minADEK (average displacement of the closest of K predicted paths) and minFDEK (its final displacement), usually with K = 20. A model that covers the true mode at all gets credit; a model that always predicts the average gets penalized at every turn. The likelihood-based alternative is the log-density of the true future under a kernel density estimate of the samples (KDE-NLL). For grasp proposers (6-DoF GraspNet in chapter 6), the analogous honest metrics are success rate of the top proposals and coverage of the feasible grasp set; for both, “average error of a random sample” is the wrong number for the same reason squared error was the wrong loss.

B7. Covariate shift and DAgger: the imitation problem a generative policy does not fix

Ross, Gordon and Bagnell, 2011. A Reduction of Imitation Learning and Structured Prediction to No-Regret Online Learning (DAgger). Pomerleau, 1989 (ALVINN), the first neural behaviour-cloning policy, and the first to run into this.

A behaviour-cloning policy, generative or not, is trained on the states the demonstrator visited. Its own small errors take it to states the demonstrator never visited, where it was never trained, where it errs more; errors compound along the trajectory, and the expected cost grows like the horizon squared rather than linearly. DAgger’s fix is procedural: run the learned policy, have the expert label the states it actually reaches, add them to the data, retrain, repeat; the policy learns to recover from its own mistakes. A CVAE cures the averaging failure this page opened with; it does nothing for compounding. Action chunking (B4) and closed-loop replanning reduce the horizon over which errors compound, noise injected into demonstrations (DART, Laskey et al., 2017) teaches recovery without an expert in the loop, and the honest evaluation is always closed-loop success rate on the robot, never per-step prediction error on held-out demonstrations.

B8. Plain autoencoders, and the change-of-variables formula behind normalizing flows

Hinton and Salakhutdinov, 2006 (deep autoencoders). Dinh, Sohl-Dickstein and Bengio, 2017 (Real NVP); Kingma et al., 2016 (inverse autoregressive flow), the “better guesses” of chapter 4’s Go deeper.

An autoencoder is a VAE with the sampling and the KL removed: encoder to a code, decoder from it, squared error, nothing else. It compresses well and generates badly: with no KL the codes are placed wherever reconstruction likes, with gaps between them, and a random code decodes to garbage. This is chapter 3’s “what reconstruction wants” picture without the other force. Many papers call a VAE with β < 1 a “regularized autoencoder”; the regularizer is the KL, and β sets how much.

A normalizing flow keeps the exact likelihood that the VAE gave up. If x = g(ε) for an invertible, differentiable g and ε ~ N(0, I), then the density of x is known exactly by the change-of-variables formula, log p(x) = log N(ε) − log |det ∂g/∂ε|, and can be maximized directly; the art is choosing g so the determinant is cheap (coupling layers, autoregressive layers). The formula also settles a question chapter 4 left implicit: z = μ + σε is a flow with one layer, |dz/dε| = σ, so log p(z) = log N(ε; 0, 1) − log σ, which is precisely the density of N(μ, σ²) evaluated at z. That is why the reparameterized draw has the right distribution.

B9. Gumbel-softmax, with numbers

Jang, Gu and Poole, 2017. Categorical Reparameterization with Gumbel-Softmax; Maddison, Mnih and Teh, 2017. The Concrete Distribution.

Chapter 4’s Go deeper names it; here is the mechanism. To sample a class from probabilities π, draw uk uniform in (0, 1), form Gumbel noise gk = −log(−log uk), and take argmaxk(log πk + gk): this is an exact sample from π, with the randomness moved into g, exactly as ε in chapter 4. The argmax has no gradient, so replace it by softmax((log π + g)/τ): a soft, differentiable choice that hardens as the temperature τ → 0.

Worked example

π = (0.2, 0.5, 0.3), so log π = (−1.61, −0.69, −1.20). Draw u = (0.31, 0.72, 0.05); then g = (−0.16, 1.11, −1.10) and log π + g = (−1.77, 0.42, −2.30): the exact sample is class 2. Softmax of the same vector at τ = 1: (0.10, 0.85, 0.06); at τ = 0.3: (0.001, 0.999, 0.000); at τ = 0.05: (0, 1, 0). Training starts warm and anneals τ toward about 0.5, trading bias (a soft choice is not a class) against variance (a hard choice has huge gradient noise).

g = -torch.log(-torch.log(torch.rand_like(logits)))                  # Gumbel noise
# differentiable; hard = soft.argmax() for an exact sample
soft = F.softmax((logits + g) / tau, dim=-1)
# library version; hard=True is one-hot with straight-through
z = F.gumbel_softmax(logits, tau=tau, hard=True)

B10. Evaluating a VAE honestly: the importance-weighted estimate of log p(x)

Burda, Grosse and Salakhutdinov, 2016. Importance Weighted Autoencoders.

Chapter 4’s Go deeper says to “importance-sample log p(x) with many draws from q”. This is the formula, and it is line 2 of chapter 3’s derivation with K draws averaged before the log:

log p(x) ≈ log (1/K) Σk p(x | zk) p(zk) / q(zk | x) = logsumexpk[ log p(x | zk) + log p(zk) − log q(zk | x) ] − log K

Worked example, on chapter 3’s toy

Truth log p(x) = −0.878; the best Gaussian guess has ELBO −0.960. Repeating the estimate 200 times at each K and averaging: K = 1 gives −0.96 (the ELBO, since a single ratio’s log is the ELBO’s integrand); K = 5 gives −0.91 ± 0.16; K = 1000 gives −0.886 ± 0.03; K = 100,000 gives −0.879 ± 0.014. The estimate is itself a lower bound for every K and tightens toward the truth as K grows. Report the ELBO during training (cheap, K = 1) and this number with K = 1000 or more at evaluation; the difference between them is the encoder’s gap made visible.

import math

@torch.no_grad()
def log_px_iwae(x, enc, dec, K=1000, sigma_x=0.1):
    mu, logvar = enc(x).chunk(2, -1)                                        # [B, d_z]
    std = torch.exp(0.5 * logvar)
    z = mu[None] + std[None] * torch.randn(K, *mu.shape)                    # [K, B, d_z]
    log_p_x_z = torch.distributions.Normal(dec(z), sigma_x).log_prob(x[None]).sum(-1)   # [K, B]
    log_p_z = torch.distributions.Normal(0., 1.).log_prob(z).sum(-1)
    log_q_z = torch.distributions.Normal(mu[None], std[None]).log_prob(z).sum(-1)
    return torch.logsumexp(log_p_x_z + log_p_z - log_q_z, dim=0) - math.log(K)          # [B]

B11. GAN neighbours you will see cited

Mirza and Osindero, 2014 (conditional GAN). Radford, Metz and Chintala, 2016 (DCGAN: the architecture and training recipe, including Adam with β₁ = 0.5, that made GANs reproducible). Chen et al., 2016 (InfoGAN). Heusel et al., 2017 (TTUR and the Fréchet inception distance). Miyato et al., 2018 (spectral normalization). Isola et al., 2017 (pix2pix) and Zhu et al., 2017 (CycleGAN), the sim-to-real translators of chapter 7. Peng et al., 2021 (adversarial motion priors, AMP).

Conditional GAN is chapter 6’s move applied to chapter 7: both G and D receive c, so D judges “is this a real grasp for this object”. InfoGAN adds a code c′ to the noise and a term that makes c′ recoverable from the output (a mutual-information bonus), which gives a GAN an interpretable latent without a KL; it is the adversarial cousin of β-VAE’s disentanglement. AMP is GAIL’s discriminator used as a style reward for legged locomotion, added to a task reward: the policy is asked to reach the goal and to move in a way D cannot distinguish from motion capture. Everything else in this list is a stabilizer already described in chapter 7.

If you read only three things after this page

  1. Kingma and Welling, 2019. An Introduction to Variational Autoencoders (Foundations and Trends in Machine Learning). The authors’ own long-form tutorial; chapters 3 to 5 of this page are its first forty pages, slowly.
  2. Zhao et al., 2023, ACT (B4) read alongside the robomimic study (B1): one CVAE and one mixture head, on real robot data, with the ablations that show which knobs mattered.
  3. Bishop, 2006, Pattern Recognition and Machine Learning, chapters 9 and 10: mixtures, the EM algorithm, and variational inference, which are the exact ancestry of the ELBO in chapter 3’s Go deeper.

Next: Glossary

Glossary

The terms I’ve used, in roughly the order they first appear.

Generative model
A model of the distribution of the data that can produce new, plausible samples from it.
Probability distribution, density
A rule assigning probability to outcomes; for continuous outcomes, a curve whose area over a range is the probability of that range.
Sample
One value drawn at random from a distribution.
Gaussian
The bell-shaped distribution N(μ, σ²), with center μ and width σ.
Likelihood, log-likelihood
The probability a model assigns to the observed data, as a function of the model's parameters; and its logarithm, which sums over data points.
Maximum likelihood
Choosing parameters to make the observed data as probable as possible.
Expectation
The average of a quantity under a distribution; estimated by averaging over samples (Monte Carlo).
KL divergence
KL(qp) = Eq[log q − log p]: how far q is from p. Non-negative, zero only when equal, not symmetric.
Jensen's inequality
For a concave function like log, the function of an average is at least the average of the function.
Latent variable
A hidden cause in the model's story of the data, written z; never observed.
Prior
The distribution of z before anything is observed; in a VAE, N(0, I).
Decoder
The network mapping z (and, in a CVAE, the situation c) to the parameters of p(x | z).
Posterior
p(z | x): which hidden causes could have produced this observation. Intractable in a VAE.
Encoder
The network that outputs the guess q(z | x), a Gaussian over z, from an observation.
ELBO
Evidence lower bound: Eq[log p(x | z)] − KL(q(z | x) ‖ p(z)) ≤ log p(x). The training objective of a VAE.
Variational inference
Approximating a posterior by the closest member of a simple family, measured by KL; maximizing the ELBO over q.
Amortized inference
Using one network to produce q for any input, instead of optimizing q separately per data point.
Reparameterization trick
Writing z = μ + σ·ε with ε ~ N(0, 1), so that a random draw becomes differentiable in μ and σ.
β
A weight on the KL term. β = 1 is the ELBO; larger values push toward the prior; equivalent to changing the decoder's spread.
Posterior collapse
The VAE failure where q(z | x) equals the prior for every x, the KL is zero, and the decoder ignores z.
KL annealing, free bits
Two remedies for collapse: raise β from 0 gradually; or exempt a floor of KL per dimension from the penalty.
Conditional VAE (CVAE)
A VAE whose encoder and decoder also receive a situation c, modeling p(x | c).
Aggregate posterior
The average of the encoder's guesses over the dataset; where it differs from the prior, prior samples fall into holes.
GAN
Generative adversarial network: a generator trained to fool a discriminator that is trained to tell generated from real.
Generator, discriminator
The two players: noise → sample, and sample → probability real.
Minimax objective
E[log D(x)] + E[log(1 − D(G(z)))], maximized by D and minimized by G.
Non-saturating loss
Training G on −log D(G(z)) instead of log(1 − D(G(z))), to keep gradients alive when fakes are bad.
Jensen–Shannon divergence
A symmetric relative of KL; what the original GAN minimizes with an optimal discriminator.
Mode collapse, mode hopping
The generator producing one kind of output; and moving that one kind around as the discriminator adapts.
Spectral normalization, gradient penalty
Constraints that keep the discriminator smooth so its gradients stay useful.
Wasserstein GAN
A GAN whose critic estimates the Earth mover's distance, with informative gradients even when distributions do not overlap.
GAIL
Adversarial imitation learning: a discriminator between expert and policy behavior serves as the reward.
Sim-to-real
Making a policy trained in simulation work on a real robot; GANs are used to translate simulated images toward real ones.
Marginal, joint, conditional
p(x), the probability of an observation summed over all causes; p(x, z), of observation and cause together; p(x | z) or p(z | x), of one given the other. Bayes’ rule relates them (crash course, A1).
Monte Carlo estimate, unbiased, standard error
Estimating an expectation by averaging f over samples. Unbiased: correct on average over repeats. Standard error: spread(f)/√n, the typical error of the estimate.
Entropy, cross-entropy, nat, bit
H(p) = −Σ p log p, the average surprise; H(p, q) = −Σ p log q; KL is their difference. A nat uses the natural log, a bit uses log base 2; 1 nat = 1.44 bits.
Log-sum-exp
Computing log Σ ei as m + log Σ eim with m the largest term, to avoid underflow.
Backpropagation
The chain rule applied backwards from the loss through every operation, giving the gradient for every weight in one pass. Cannot pass through a random draw; the reparameterization trick moves the draw out of its way.
Adam
An optimizer that scales each weight’s step by a running estimate of its gradient’s size, so steps are about the learning rate regardless of gradient scale; β₁ sets the momentum’s memory (0.9 ≈ 10 steps, 0.5 ≈ 2).
Standardization
Subtracting the mean and dividing by the standard deviation of each data coordinate before training, so that a decoder spread such as σx = 0.1 means the same thing on every dataset.
Reduction (sum vs mean)
Whether a loss adds or averages over data dimensions. A log-likelihood sums; averaging silently divides the reconstruction term by the data dimension and multiplies the effective β by it.
Mixture density network (MDN)
A network whose output is the weights, means and spreads of a mixture of Gaussians; trained by exact negative log-likelihood with log-sum-exp. The other classic answer to multimodal outputs.
Straight-through estimator
Using a non-differentiable operation (argmax, nearest code) in the forward pass and pretending it was the identity in the backward pass. Used by VQ-VAE and hard Gumbel-softmax.
VQ-VAE, codebook collapse
A VAE whose latent is the nearest of K learned code vectors; no KL term. Codebook collapse: most codes are never selected.
Gumbel-softmax
A reparameterization for categorical variables: argmax(log π + Gumbel noise) is an exact sample, and softmax((log π + noise)/τ) is its differentiable relaxation.
Action chunking, temporal ensembling
Predicting a sequence of future actions at once, and averaging the overlapping predictions made at successive steps with exponentially decaying weights. Used by ACT.
ACT
Action Chunking with Transformers: a CVAE policy whose decoder emits 100-step action chunks; L1 reconstruction, β = 10, latent set to zero at run time.
Covariate shift, DAgger
A cloned policy visits states the demonstrator never did, and errs there; errors compound with the horizon. DAgger fixes it by having the expert label the states the policy actually reaches.
minADEK, minFDEK
Best-of-K metrics for multimodal predictors: the average (or final) displacement error of the closest of K sampled trajectories to the one that happened.
Importance-weighted bound (IWAE)
logsumexpk[log p(x | zk) + log p(zk) − log q(zk | x)] − log K: a lower bound on log p(x) that tightens as K grows; the honest evaluation number for a VAE.
Change of variables
If x = g(ε) with g invertible, log p(x) = log p(ε) − log |det ∂g/∂ε|. Explains why μ + σε has distribution N(μ, σ²), and is the basis of normalizing flows.
Lipschitz, spectral normalization, gradient penalty
A function is 1-Lipschitz if it never stretches distances; the Wasserstein critic must be. Spectral normalization divides each weight matrix by its largest singular value; the gradient penalty penalizes ‖∇D‖ ≠ 1 at interpolated points.
Earth mover’s (Wasserstein-1) distance
The cost of moving one distribution’s mass onto another’s. Unlike JS, it grows with the distance between non-overlapping distributions, so it has a useful gradient.

The VAE in chapter 4 and the GAN in chapter 7 train live in your browser from random weights each time you press Reset; the trajectory model in chapters 0, 2 and 6 was trained offline with the conditional ELBO and its weights are embedded in the page. All other numbers are computed live from the formulas shown.

The worked examples and code boxes were computed offline with numpy and scipy (the ELBO toy on a 1201-point grid, the VAE lab as a numpy port of the page’s own training code, the KL values from the closed forms); the Monte Carlo bench in chapter 1 runs live. Code boxes are PyTorch 2.x and were written to match the shapes and settings of the page’s embedded models exactly.