Problem
A plain autoencoder compresses well but cannot generate: decode a random code and you usually get garbage, because nothing decided what the space between training codes should mean. We want a model we can sample from. Pick a simple distribution p(z) for a hidden code, say a standard normal, and a decoder pθ(x | z) that turns a code into data. Sampling is then easy: draw z, decode.
Training is the hard part. Maximum likelihood needs pθ(x) = ∫ pθ(x | z) p(z) dz, an integral over every possible code, which has no closed form once the decoder is a neural network. The posterior pθ(z | x), which code explains this x, needs that same integral through Bayes' rule. Kingma and Welling's answer (and, independently, Rezende, Mohamed and Wierstra's) was to learn an encoder that approximates the posterior and to optimize a bound that only needs samples from it.
Intuition
The encoder no longer outputs one code but a small cloud: a mean and a spread. The decoder must reconstruct x from any point in the cloud, so nearby codes are forced to decode to similar data. A second term pulls every cloud toward the standard normal prior, so the clouds of all training examples overlap and tile the space with no gaps. Reconstruction wants the clouds small and far apart; the prior term wants them wide and centred. The balance is a smooth, densely used code space, and then a fresh draw from the prior lands somewhere the decoder knows.
The trick that makes this trainable is to move the randomness out of the way of the gradient: instead of sampling z from N(μ, σ²), sample ε from N(0, 1) and compute z = μ + σε. Now z is a differentiable function of μ and σ, and backpropagation flows straight through.
The noise is an input, not a node to differentiate through, so the sample is a deterministic function of what the encoder outputs.
Mechanics
The ELBO, derived
For any distribution q(z) over codes, multiply and divide inside the integral and apply Jensen's inequality (log of an average is at least the average of the log):
log p(x) = log 𝔼q(z)[ p(x | z) p(z) / q(z) ] ≥ 𝔼q(z)[ log p(x | z) ] − KL(q(z) ‖ p(z)) = ELBO
The first term is a reconstruction score: decode codes drawn from q and measure how likely x is. The second keeps q close to the prior, measured by the KL divergence. The bound is not loose by an unknown amount; expanding p(x | z) p(z) = p(z | x) p(x) gives an exact identity:
log p(x) = ELBO(q) + KL(q(z) ‖ p(z | x))
So raising the ELBO over q pushes q toward the true posterior, and the gap is exactly how far it still is. A VAE amortizes this: instead of fitting a separate q for every x, an encoder network outputs qφ(z | x) = N(μφ(x), diag σφ²(x)), and the encoder φ and decoder θ are trained together to maximize the average ELBO over the data. Raising it either tightens the bound (better encoder) or raises log p(x) itself (better decoder).
The KL term in closed form
For two one-dimensional normals the KL has a closed form, which is what PyTorch's registered _kl_normal_normal computes:
KL(N(μp, σp²) ‖ N(μq, σq²)) = ½ [ (σp/σq)² + ((μp − μq)/σq)² − 1 − log (σp/σq)² ]
Against the standard normal prior and with the encoder predicting log σ² (so σ is always positive), it is ½ (σ² + μ² − 1 − log σ²) per dimension, summed over the independent latent dimensions. It is zero only when μ = 0 and σ = 1, that is, when the code says nothing about x. Predicting log σ² rather than σ is the usual choice because it is unconstrained and well-behaved near zero variance.
The reparameterization trick
The reconstruction term 𝔼q[log p(x | z)] has no closed form for a neural decoder, so it is estimated with samples, usually one per example. We need its gradient with respect to the encoder, but a sample from N(μ, σ²) is not a differentiable function of μ and σ.
Writing z = μ + σ ⊙ ε with ε ~ N(0, I) fixes that: ∂z/∂μ = 1 and ∂z/∂σ = ε. PyTorch makes the distinction explicit: Normal.rsample returns loc + eps * scale with a fresh standard normal ε and keeps the graph, while sample runs under torch.no_grad().
has_rsample = True advertises that a distribution supports it. The alternative, the score-function (REINFORCE) estimator f(z) ∇ log q(z), works for any distribution, including discrete ones, but its variance is typically far higher, as the worked example shows.
β-VAE, posterior collapse and latent diffusion
- β-VAE. Higgins et al. weight the KL term by β: loss = reconstruction + β · KL. With β > 1 the code is pushed harder toward the factorized prior, which tends to give more disentangled, individually interpretable latent dimensions at the cost of blurrier reconstructions. β < 1 does the reverse.
- Posterior collapse. If the decoder is powerful enough to model x without the code (an autoregressive decoder over text, say), the cheapest optimum sets q(z | x) = p(z) for every x: KL is zero and z is ignored. Individual dimensions can collapse too; they show up as units with μ ≈ 0, σ ≈ 1 and KL ≈ 0. KL annealing (start β near 0 and raise it) and free bits (no penalty below a per-dimension KL floor) are the usual remedies.
- Blurriness. A Gaussian decoder with fixed variance makes the reconstruction term a squared error, which favours averaged outputs. VAE samples are smooth rather than sharp.
- Latent diffusion. Stable Diffusion and its relatives first train an autoencoder with a small KL penalty (plus perceptual and adversarial losses) to map images to a smaller latent grid, then run diffusion in that latent space. The VAE does the compression; the diffusion model does the generating.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
Normal(loc, scale) |
loc real, scale positive; broadcast together; batch_shape = loc.size() | Diagonal Gaussian over each latent dimension; has_rsample = True. |
normal.py 15-37, 55-65 |
Normal.sample, Normal.rsample |
sample_shape + batch_shape | sample under no_grad; rsample returns loc + eps * scale, differentiable in loc and scale. |
normal.py 77-85 |
_standard_normal |
ε of the extended shape | Draws the noise with torch.empty(...).normal_(). |
utils.py 62-73 |
Normal.log_prob, entropy |
−(v − μ)²/(2σ²) − log σ − log √(2π); ½ + ½ log 2π + log σ | Gaussian decoder log-likelihood; entropy of q. | normal.py 87-101, 113-114 |
kl_divergence(p, q) |
result of shape batch_shape | Looks up the most specific registered pair and memoizes it; raises NotImplementedError otherwise. | kl.py 51-60, 165-193 |
_kl_normal_normal |
½(var_ratio + t1 − 1 − log var_ratio) | The closed-form Gaussian KL used for the VAE's prior term. | kl.py 467-471 |
Independent(base, 1) |
moves the last batch dim into event_shape | Turns per-dimension Normals into one diagonal Gaussian, so log_prob and KL sum over latent dimensions. |
independent.py 18-60, 120-122, kl.py 944-949 |
Distribution.sample, rsample |
base class | sample defaults to rsample under no_grad; rsample raises unless a subclass implements it. |
distribution.py 177-191 |
An encoder outputs μ = 0 and log σ² = 0 for one latent dimension on every input. What is that dimension's KL, and what does it tell you?
½(1 + 0 − 1 − 0) = 0. The posterior equals the prior whatever the input, so the dimension carries no information about x: it has collapsed. The decoder ignores it, and the KL term has no reason to change that.
Worked example
An ELBO you can check by hand. Take a one-dimensional latent-variable model with a linear decoder: z ~ N(0, 1), x | z ~ N(2.0·z, 1.0²). Everything is Gaussian, so the integrals a VAE has to approximate are exact here. The evidence is x ~ N(0, 2.0² + 1.0²) = N(0, 2.236²), so for the observation x = 3.0, log p(x) = −2.6237. The true posterior is N(wx/(w² + s²), s²/(w² + s²)) = N(1.200, 0.447²).
The reconstruction term has a closed form too: 𝔼q[log p(x | z)] = −½ log(2πs²) − ((x − w·m)² + w²v²)/(2s²) for q = N(m, v²). For four choices of q:
| q(z) | 𝔼q[log p(x|z)] | KL(q ‖ p(z)) | ELBO | log p(x) − ELBO | KL(q ‖ p(z|x)) |
|---|---|---|---|---|---|
| N(0.000, 1.000²) prior | −7.4189 | 0.0000 | −7.4189 | 4.7953 | 4.7953 |
| N(1.000, 0.500²) | −1.9189 | 0.8181 | −2.7371 | 0.1134 | 0.1134 |
| N(1.500, 0.200²) | −0.9989 | 2.2544 | −3.2534 | 0.6297 | 0.6297 |
| N(1.200, 0.447²) posterior | −1.4989 | 1.1247 | −2.6237 | 0.0000 | 0.0000 |
The last two columns agree on every row, which is the identity log p(x) = ELBO + KL(q ‖ p(z | x)). Using the prior as q costs nothing in KL but reconstructs badly. The narrow guess N(1.500, 0.200²) reconstructs best of all, but pays 2.2544 in KL and ends with a worse ELBO than N(1.000, 0.500²). Only the true posterior closes the gap to zero, and its ELBO equals log p(x) exactly.
Two gradient estimators. Estimate ∂/∂μ 𝔼[(z − 1.0)²] for z ~ N(0.5, 0.8²). The exact answer is 2(μ − 1.0) = −1.000. With 1000 draws (seed 11):
| Estimator | Per-sample estimate | Mean | Standard deviation |
|---|---|---|---|
| Reparameterization | 2(μ + σε − c) | −0.962 | 1.614 |
| Score function | (z − c)² · ε/σ | −0.797 | 3.412 |
Both are unbiased, but the score-function estimate is about 2.1 times noisier per sample, so it needs roughly 4 times as many samples for the same precision. Reparameterization uses the derivative of f; the score function only uses its value.
The KL term on one code. An encoder outputs, for a four-dimensional latent:
| Dimension | μ | log σ² | σ | ½(σ² + μ² − 1 − log σ²) |
|---|---|---|---|---|
| 1 | 1.20 | −2.00 | 0.368 | 1.2877 |
| 2 | −0.40 | −0.50 | 0.779 | 0.1333 |
| 3 | 0.00 | 0.00 | 1.000 | 0.0000 |
| 4 | 0.02 | −0.01 | 0.995 | 0.0002 |
| Sum | 1.4212 |
Dimension 1 carries information (a confident, off-centre code); dimensions 3 and 4 are at or near the prior and have collapsed. With β = 0.5, 1.0, 4.0 the KL penalty is 0.711, 1.421, 5.685, which is what the reconstruction term must outweigh for the code to stay informative.
The ELBO's gap is the KL between the dashed q and the true posterior; a VAE's encoder is trained to shrink it for every x at once.
Implementation
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Normal, Independent, kl_divergence
class VAE(nn.Module):
def __init__(self, d_in=784, hidden=400, latent=16):
super().__init__()
self.enc = nn.Sequential(nn.Linear(d_in, hidden), nn.ReLU())
self.mu = nn.Linear(hidden, latent)
self.logvar = nn.Linear(hidden, latent)
self.dec = nn.Sequential(nn.Linear(latent, hidden), nn.ReLU(), nn.Linear(hidden, d_in))
def forward(self, x):
h = self.enc(x)
mu, logvar = self.mu(h), self.logvar(h)
q = Independent(Normal(mu, torch.exp(0.5 * logvar)), 1) # event_shape (latent,)
z = q.rsample() # mu + sigma * eps, keeps the graph
return self.dec(z), q
def loss_fn(logits, x, q, beta=1.0):
recon = F.binary_cross_entropy_with_logits(logits, x, reduction="none").sum(-1) # -log p(x|z)
prior = Independent(Normal(torch.zeros_like(q.mean), torch.ones_like(q.mean)), 1)
kl = kl_divergence(q, prior) # closed form, summed over latent dims
# same as 0.5 * (logvar.exp() + mu**2 - 1 - logvar).sum(-1)
return (recon + beta * kl).mean() # negative ELBO per example
model = VAE()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
for x, _ in loader:
x = x.view(x.size(0), -1) # pixels in [0, 1]
logits, q = model(x)
loss = loss_fn(logits, x, q)
opt.zero_grad()
loss.backward()
opt.step()
with torch.no_grad():
samples = torch.sigmoid(model.dec(torch.randn(64, 16))) # generate from the prior
Sum the reconstruction over pixels and the KL over latent dimensions before averaging over the batch. Averaging the reconstruction over pixels (the default reduction="mean") silently shrinks it by a factor of 784 relative to the KL, which acts like a huge β and is a common cause of collapse.
Independent(..., 1) does that summing for both log_prob and kl_divergence. To report log p(x) rather than the bound, use importance sampling with several draws from q.
Tradeoffs
- Likelihood and a smooth latent space versus sharpness. VAEs give a lower bound on likelihood, stable training and an encoder for free, but samples are blurrier than those of GANs and diffusion models, which is why modern image generators use a VAE only for compression.
- Amortization gap. One encoder serves every x, so even at the optimum its q can be worse than the best Gaussian for a particular x. More expressive posteriors (normalizing flows) or a few refinement steps per example shrink the gap.
- The Gaussian assumption. A diagonal Gaussian q cannot represent correlated or multimodal posteriors; the ELBO then underestimates log p(x) by at least that mismatch.
- β is a real knob. Higher β gives more structured, disentangled codes and worse reconstructions; lower β the reverse. The right setting depends on whether you want samples, features or compression.
- Discrete latents need other tools. Reparameterization needs a continuous z. Discrete codes use the score-function estimator, a continuous relaxation, or vector quantization with a straight-through gradient.
Why not just maximize the reconstruction term and drop the KL?
Then the encoder can shrink σ toward 0 and spread the μs apart, which turns the model back into a plain autoencoder: excellent reconstructions, but codes from the prior land in regions the decoder never saw, so sampling fails. The KL term is what makes p(z) a good place to sample from.
Connections
Read first
Read next
- Diffusion models advanced
References
- Kingma and Welling. Auto-Encoding Variational Bayes. ICLR 2014. arXiv:1312.6114
- Rezende, Mohamed and Wierstra. Stochastic Backpropagation and Approximate Inference in Deep Generative Models. ICML 2014. arXiv:1401.4082
- Williams. Simple Statistical Gradient-Following Algorithms for Connectionist Reinforcement Learning. Machine Learning, 1992.
- Higgins et al. β-VAE: Learning Basic Visual Concepts with a Constrained Variational Framework. ICLR 2017.
- Bowman et al. Generating Sentences from a Continuous Space. CoNLL 2016. arXiv:1511.06349
- Kingma et al. Improved Variational Inference with Inverse Autoregressive Flow. NeurIPS 2016. arXiv:1606.04934
- Cremer, Li and Duvenaud. Inference Suboptimality in Variational Autoencoders. ICML 2018. arXiv:1801.03558
- Kingma and Welling. An Introduction to Variational Autoencoders. Foundations and Trends in Machine Learning, 2019. arXiv:1906.02691
- Rombach, Blattmann, Lorenz, Esser and Ommer. High-Resolution Image Synthesis with Latent Diffusion Models. CVPR 2022. arXiv:2112.10752
- PyTorch v2.15.0:
torch/distributions/normal.py,torch/distributions/kl.py,torch/distributions/independent.py.