Skip to main content

VAE: Auto-Encoding Variational Bayes

How the variational autoencoder turns an autoencoder into a generative model: an encoder predicts a Gaussian latent distribution, the reparameterization trick makes sampling differentiable, and the ELBO balances reconstruction against a KL prior.

TL;DR

  • A VAE replaces the deterministic bottleneck of a standard autoencoder with a probabilistic one: the encoder outputs the mean \mu and standard deviation \sigma of a Gaussian, samples a latent code z, and the decoder reconstructs the input from z.
  • Training maximizes the Evidence Lower Bound (ELBO): a reconstruction term that pushes the decoder to reproduce the input, minus a KL divergence that regularizes the latent distribution toward \mathcal{N}(0, I).
  • The reparameterization trick — rewriting z = μ + σ ⊙ ε with \varepsilon \sim \mathcal{N}(0, I) — is the key that lets gradients flow through the sampling step so the model can be trained end-to-end.
  • The KL penalty encourages posterior distributions toward the prior, making sampling and latent interpolation useful possibilities. Semantic smoothness depends on the learned model and data; standard autoencoders can also interpolate.

Encoder, latent, decoder

A classical autoencoder learns a deterministic mapping from input to code to reconstruction. Its reconstruction objective alone does not match the encoded data distribution to a chosen sampling prior. The VAE adds a probabilistic encoder and an explicit prior-based objective.

Given an input x, the encoder outputs two vectors — a mean \mu(x) and a log-variance \log\sigma^2(x) — that define a diagonal Gaussian:

q_\phi(z|x) = \mathcal{N}\bigl(z; \mu(x), \operatorname{diag}(\sigma^2(x))\bigr)

A latent code z is sampled from this distribution and passed to the decoder. The decoder returns parameters of p_\theta(x|z), such as Bernoulli probabilities for binary observations or Gaussian parameters for real-valued data. A reconstruction can be formed from that likelihood, but is not itself the full distribution.

The training objective is the ELBO (Evidence Lower Bound):

\mathcal{L}(\phi, \theta; x) = \underbrace{\mathbb{E}_{q_\phi(z|x)}\bigl[\log p_\theta(x|z)\bigr]}_{\text{reconstruction}} - \underbrace{D_\mathrm{KL}\bigl(q_\phi(z|x) \,\|\, p(z)\bigr)}_{\text{KL regularization}}

The reconstruction term rewards the decoder for accurately recovering x. The KL term penalises the approximate posterior q_\phi(z|x) for drifting away from the prior p(z) = \mathcal{N}(0,I). For a diagonal Gaussian encoder the KL has a closed form:

D_\mathrm{KL} = -\frac{1}{2}\sum_{j=1}^{d}\bigl(1 + \log\sigma_j^2 - \mu_j^2 - \sigma_j^2\bigr)

The reparameterization trick

Treating a draw from q_\phi(z|x) as a black-box sampling operation does not expose a pathwise derivative. We need gradients to flow back to the encoder parameters \phi through the sampled z.

The reparameterization trick sidesteps this by separating the randomness from the learnable parameters:

z = \mu + \sigma \odot \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, I)

Now z is a deterministic function of \mu, \sigma, and an exogenous noise \varepsilon. The stochasticity lives in \varepsilon, which has no learnable parameters, so gradients can flow freely through \mu and \sigma via the chain rule. Without this trick, training the inference network end-to-end would require high-variance score-function estimators.

Implementation

The differentiable sample becomes useful when it reaches an explicit likelihood. This reduced reference uses binary inputs and a Bernoulli decoder, then minimizes negative ELBO: sum the likelihood over features and KL over latent dimensions for each sample, and average the batch. With fixed noise, its gradients can be checked numerically all the way back to both encoder heads.

Authored seeded offline CPU mechanics in PyTorch float64: three synthetic binary inputs, four features, two latent coordinates, one posterior sample and one SGD update. This is a tiny Bernoulli reference with no pretrained model, image dataset, semantic interpolation or benchmark reproduction. Equations use local notation for the paper’s likelihood and negative ELBO; dimensions and the batch mean are explicit teaching choices.

1. Predict a mean and a log variance

Original paper

Appendix C.2, Equation 12 · Gaussian encoder

Two heads parameterize a diagonal Gaussian posterior.

Math and tensor shapes

h=\tanh(W_xx+b_x)
\mu=W_\mu h+b_\mu,\quad\ell=W_\ell h+b_\ell

The tiny tanh MLP has separate mean and log-variance heads. Here ℓ is log(σ²), so each sample has covariance diag(exp(ℓ)), not a shared scalar variance. The hidden width 5 and latent width 2 are authored reductions. The seeded constructor preserves the caller’s global random state.

x [3,4] → h [3,5] → mu, logvar [3,2]

Python implementation

1. Predict a mean and a log variance
python
class Encoder(nn.Module):
    """Map [B,Dx] inputs to [B,Dz] means and log variances (Appendix C.2)."""

    def __init__(self, input_dim, hidden_dim, latent_dim):
        super().__init__()
        self.hidden = nn.Linear(input_dim, hidden_dim)
        self.mu_head = nn.Linear(hidden_dim, latent_dim)
        self.logvar_head = nn.Linear(hidden_dim, latent_dim)

    def forward(self, x):
        """Return diagonal Gaussian parameters; logvar is log(sigma squared)."""
        check_matrix(x, "input", self.hidden.in_features)
        if x.dtype != self.hidden.weight.dtype:
            raise ValueError("input dtype must match encoder parameters")
        h = torch.tanh(self.hidden(x))
        return self.mu_head(h), self.logvar_head(h)

2. Sample with an explicit noise tensor

Original paper

§3, Equation 9; §2.4 · Reparameterization

Independent noise turns sampling into a differentiable map.

Math and tensor shapes

\varepsilon\sim\mathcal N(0,I)
\sigma=\exp(\ell/2)
z=\mu+\sigma\odot\varepsilon

The fixture supplies one posterior sample per input using a local standard-normal generator. Holding epsilon fixed makes finite differences meaningful. exp(0.5 × logvar) converts log variance to standard deviation; using exp(logvar) here would sample the wrong variance. Shape and dtype checks reject implicit broadcasting.

mu, logvar, epsilon and z all [3,2]

Python implementation

2. Sample with an explicit noise tensor
python
def reparameterize(mu, logvar, epsilon):
    """Use supplied standard-normal noise, preserving encoder gradients.

    Args:
        mu: Posterior means, shaped [B,Dz].
        logvar: Log variances, with the same shape and dtype as mu.
        epsilon: Independent standard-normal noise, shaped like mu.

    Returns:
        A differentiable [B,Dz] posterior sample.
    """
    check_pair(mu, logvar, "mu", "logvar")
    check_pair(mu, epsilon, "mu", "epsilon")
    std = torch.exp(0.5 * logvar)
    z = mu + std * epsilon
    check_matrix(z, "latent sample")
    return z

3. Decode Bernoulli likelihood parameters

Original paper

Appendix C.1, Equation 11 · Bernoulli decoder

Binary observations use a factorized Bernoulli likelihood.

Math and tensor shapes

a=W_o\tanh(W_zz+b_z)+b_o
y=\operatorname{sigmoid}(a)

The decoder returns logits, with sigmoid probabilities used only for inspection. The four input features are synthetic binary observations. These probabilities come from the sampled codes before the single optimizer update; they are neither sampled images nor evidence of learned reconstruction quality.

z [3,2] → decoder hidden [3,5] → logits a [3,4]

Python implementation

3. Decode Bernoulli likelihood parameters
python
class Decoder(nn.Module):
    """Return [B,Dx] Bernoulli logits from [B,Dz] codes (Appendix C.1)."""

    def __init__(self, latent_dim, hidden_dim, input_dim):
        super().__init__()
        self.hidden = nn.Linear(latent_dim, hidden_dim)
        self.output = nn.Linear(hidden_dim, input_dim)

    def forward(self, z):
        """Return logits, rather than thresholded pixels or sampled images."""
        check_matrix(z, "latent sample", self.hidden.in_features)
        if z.dtype != self.hidden.weight.dtype:
            raise ValueError("latent dtype must match decoder parameters")
        return self.output(torch.tanh(self.hidden(z)))

4. Sum the negative log likelihood over features

Original paper

Appendix C.1, Equation 11 · Feature likelihood

The log likelihood adds the independent feature contributions.

Math and tensor shapes

r_{bd}=\operatorname{softplus}(a_{bd})-x_{bd}a_{bd}
n_b=\sum_{d=1}^{D_x}r_{bd}

Stable binary cross-entropy with logits evaluates the negative of Equation 11 without taking log of rounded sigmoid values. The feature sum preserves the likelihood’s scale; a mean over all pixels would change its balance against KL. Inputs must be exactly binary. This is a declared Bernoulli likelihood, not an arbitrary MSE proxy.

element NLL [3,4] → per-sample nll [3]

Python implementation

4. Sum the negative log likelihood over features
python
def reconstruction_nll(logits, x):
    """Return feature-summed Bernoulli negative log likelihood per sample."""
    check_pair(logits, x, "logits", "input")
    if not bool(((x == 0) | (x == 1)).all()):
        raise ValueError("Bernoulli observations must be binary")
    element_nll = F.binary_cross_entropy_with_logits(
        logits, x, reduction="none"
    )
    return element_nll.sum(dim=1)

5. Sum the analytic KL over latent dimensions

Original paper

Appendix B; §3, Equation 10 · Gaussian KL

The Gaussian KL can be evaluated without Monte Carlo noise.

Math and tensor shapes

c_{bj}=\tfrac12(\mu_{bj}^2+e^{\ell_{bj}}-1-\ell_{bj})
k_b=\sum_{j=1}^{D_z}c_{bj}

Appendix B gives negative KL because the paper maximizes ELBO. This code uses positive KL in a minimized loss. For μ=0 and logvar=0 it is zero; independent scalar checks confirm the latent sum and nonnegativity on numerical fixtures. The prior is N(0,I).

mu and logvar [3,2] → per-sample kl [3]

Python implementation

5. Sum the analytic KL over latent dimensions
python
def analytic_kl(mu, logvar):
    """Sum diagonal Gaussian-to-standard-normal KL over latent dimensions."""
    check_pair(mu, logvar, "mu", "logvar")
    element_kl = 0.5 * (mu.square() + logvar.exp() - 1 - logvar)
    per_sample = element_kl.sum(dim=1)
    if not bool(torch.isfinite(per_sample).all()):
        raise ValueError("KL overflowed")
    return per_sample

6. Minimize the negative ELBO with beta=1

Original paper

§3, Equation 10; §2.3, Equations 7–8 · Objective

The reconstruction estimator and analytic KL form the objective.

Math and tensor shapes

\widehat{\mathcal L}_b=-n_b-k_b
J=\frac1B\sum_{b=1}^{B}(n_b+k_b)

Both sums happen inside each sample, then the batch mean supplies the optimizer loss. This minimizes a one-sample estimate of negative ELBO. The original objective has beta=1; changing that weight is a later variant. The paper’s N/B-scaled dataset sum is a different overall normalization, not a reason to average features or latent coordinates.

nll [3] + kl [3] → scalar objective []

Python implementation

6. Minimize the negative ELBO with beta=1
python
def negative_elbo(logits, x, mu, logvar):
    """Minimize a one-sample negative ELBO; beta=1 and batch reduction=mean.

    Args:
        logits: Decoder Bernoulli logits, shaped [B,Dx].
        x: Binary observations, shaped like logits.
        mu: Posterior means, shaped [B,Dz].
        logvar: Posterior log variances, shaped like mu.

    Returns:
        Scalar loss, per-sample feature-summed NLL, and latent-summed KL.
    """
    nll = reconstruction_nll(logits, x)
    kl = analytic_kl(mu, logvar)
    if nll.shape != kl.shape or logits.dtype != mu.dtype:
        raise ValueError("likelihood and posterior must share batch and dtype")
    loss = (nll + kl).mean()
    if not bool(torch.isfinite(loss)):
        raise ValueError("objective overflowed")
    return loss, nll, kl

7. Backpropagate into both networks and update

Original paper

Algorithm 1; §2.3 · Joint parameter update

The inference and generative parameters are optimized jointly.

Math and tensor shapes

g_\phi=\nabla_\phi J,\quad g_\theta=\nabla_\theta J
\omega^+=\omega-\eta g_\omega

The reference evaluates the sampled objective, clears accumulated gradients, backpropagates, checks finite gradients and takes one SGD step. Fixed-noise finite differences test both encoder heads. Independent before/after checks verify each parameter equals its old value minus learning rate times its recorded gradient. The smoke reports actual latent samples, loss terms, head gradient norms and state changes; one step does not establish training convergence.

encoder + decoder parameters → gradients + actual SGD state change

Python implementation

7. Backpropagate into both networks and update
python
def training_step(encoder, decoder, x, epsilon, learning_rate=0.01):
    """Backpropagate both networks and take one SGD step with explicit noise.

    Args:
        encoder: A float CPU Encoder module.
        decoder: A compatible Decoder module.
        x: Binary observations, shaped [B,Dx].
        epsilon: Independent standard-normal noise, shaped [B,Dz].
        learning_rate: Positive finite SGD learning rate.

    Returns:
        Detached pre-update samples and objective terms, recorded gradients,
        and actual parameter-change evidence after the SGD update.
    """
    if not math.isfinite(learning_rate) or learning_rate <= 0:
        raise ValueError("learning_rate must be finite and positive")
    mu, logvar = encoder(x)
    z = reparameterize(mu, logvar, epsilon)
    logits = decoder(z)
    loss, nll, kl = negative_elbo(logits, x, mu, logvar)
    named = [("encoder." + k, p) for k, p in encoder.named_parameters()]
    named += [("decoder." + k, p) for k, p in decoder.named_parameters()]
    before = {name: p.detach().clone() for name, p in named}
    optimizer = torch.optim.SGD([p for _, p in named], lr=learning_rate)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    gradients = {name: p.grad.detach().clone() for name, p in named}
    if not all(bool(torch.isfinite(g).all()) for g in gradients.values()):
        raise ValueError("nonfinite gradients; parameters were not updated")
    optimizer.step()
    delta = sum(
        float((p.detach() - before[name]).square().sum()) for name, p in named
    )
    return {
        "mu": mu.detach(),
        "logvar": logvar.detach(),
        "z": z.detach(),
        "logits": logits.detach(),
        "objective": loss.detach(),
        "reconstruction_nll": nll.detach(),
        "kl": kl.detach(),
        "gradients": gradients,
        "parameter_delta_l2": math.sqrt(delta),
        "finite_parameters": all(
            bool(torch.isfinite(p).all()) for _, p in named
        ),
        "both_networks_updated": all(
            any(
                not torch.equal(p.detach(), before[name])
                for name, p in named
                if name.startswith(prefix)
            )
            for prefix in ("encoder.", "decoder.")
        ),
    }

Run the smoke to inspect source SHA-256, seed, exact reductions, shapes, finite outputs, gradients and the parameter update. Bernoulli probabilities are pre-update likelihood parameters, not trained reconstruction results.

vae.pyrequirements-vae.txt

python -m pip install -r requirements-vae.txt
python vae.py --smoke --seed 7

A structured latent space

The code verifies the loss and gradient path, not semantic behavior. KL encourages each approximate posterior toward \mathcal{N}(0, I), but it does not force the aggregate posterior across training inputs to match the prior exactly. Reconstruction and regularization compete, and the resulting latent structure depends on data, capacity and optimization.

Continuous decoders can produce continuous interpolations, but that alone does not guarantee meaningful outputs along the path. Standard autoencoders can also interpolate; their reconstruction objective does not explicitly encourage a chosen prior. The demonstration below traces two authored coordinates and morphs a shape with a handcrafted rule. It illustrates interpolation geometry, without a learned encoder, learned decoder or semantic reconstruction claim.

Why it mattered

VAEs were the first deep generative models with a principled, differentiable inference procedure. Earlier approaches like restricted Boltzmann machines required MCMC-based approximate inference; GANs (proposed the same year) avoid inference entirely at the cost of training instability and no explicit likelihood. The VAE gave practitioners both: a stable training objective (ELBO) and an inference network that runs in a single forward pass at test time.

The latent space idea has proven foundational. Latent Diffusion Models (Stable Diffusion) use a VAE to compress images to a compact latent before running the expensive diffusion process — the VAE is precisely the stage that makes high-resolution diffusion practical on consumer hardware. The conceptual framework of encoding inputs into a structured Gaussian latent and decoding from that latent has become a standard building block across generative modelling, representation learning, and controllable synthesis.

  • Latent Diffusion — uses a VAE as its first stage to compress images into a latent space where diffusion is computationally feasible
  • DDPM — denoising diffusion probabilistic models, the other major generative paradigm that competes with and complements VAEs
  • GAN — generative adversarial networks, proposed the same year, offering an adversarial alternative without explicit likelihoods

If you found this paper review helpful, consider sharing it with others.

Mastodon