Back to Generative AI
AI/ML · Generative AI

Variational Autoencoder

An autoencoder that is forced to be probabilistic: instead of mapping an input to a single point in latent space, the encoder outputs a small Gaussian, and the decoder must reconstruct from a sample of it. Because nearby latent points now decode to similar outputs, the latent space becomes continuous rather than a scatter of memorized codes — which is what makes sampling from it produce something new rather than noise.

UnsupervisedSelf-supervisedGenerationDensity estimationDimensionality reductionAnomaly detection

Also known as: VAE, Auto-Encoding Variational Bayes

Intuition

What it actually does

An autoencoder that is forced to be probabilistic: instead of mapping an input to a single point in latent space, the encoder outputs a small Gaussian, and the decoder must reconstruct from a sample of it. Because nearby latent points now decode to similar outputs, the latent space becomes continuous rather than a scatter of memorized codes — which is what makes sampling from it produce something new rather than noise.

Assumptions it makes
  • The data has a low-dimensional latent cause worth recovering
  • A Gaussian approximate posterior is expressive enough for the true posterior
  • The chosen likelihood matches the data type — Gaussian for continuous, Bernoulli for binary, and not interchangeably
  • Training data is representative; anything absent will be assigned low likelihood, correctly or not
Objective

Evidence lower bound

Maximize the evidence lower bound, not a loss: reconstruct the input well, while keeping the encoder close to the prior. The two terms pull against each other on purpose — pure reconstruction memorizes, pure regularization collapses to the prior and ignores the input, and the useful model lives between them. It is a bound because the true log-likelihood is intractable; the gap is the KL between the approximate and true posterior.

the encoder — an approximate posterior, a Gaussian per input
the decoder — the likelihood of the data given a latent code
the prior, almost always a standard normal
how far the encoder has drifted from the prior
Optimization

How it is minimized

Stochastic gradient ascent on the ELBO via the reparameterization trick, with Adam

the encoder outputs — mean and standard deviation of the posterior
the noise, sampled OUTSIDE the graph so the gradient can flow past it
elementwise product

The whole difficulty is that you cannot backpropagate through a sampling operation. The reparameterization trick moves the randomness into an input: rather than sampling z from a distribution whose parameters you want gradients for, you sample fixed noise and transform it deterministically by those parameters. The sampling stops being a node in the graph and the gradient flows straight through. Without this, a VAE is not trainable by gradient descent at all.

Hyperparameters
  • latent dimension

    Bottleneck width; the main lever on both reconstruction and how usable the space is (2 to 512)

  • beta (KL weight)

    Rebalances the two terms; >1 buys disentanglement at the cost of reconstruction (0.1 to 10)

  • KL warm-up epochs

    Anneals the KL term up from zero to prevent early posterior collapse (0 to 30)

  • decoder capacity

    A decoder strong enough to ignore z will do exactly that (match to the encoder)

Convergence & cost

Non-convex, and it converges reliably — the characteristic failure is not divergence but posterior collapse: the KL term drives to zero, the encoder outputs the prior regardless of input, and the decoder learns the unconditional mean. The ELBO looks respectable throughout and the model has learned nothing. Watch the KL term itself, not the total. The other well-known symptom is blurry samples, which is not a bug but the direct consequence of a Gaussian likelihood, whose optimum under uncertainty is an average.

One encoder and one decoder pass per sample, so roughly twice a plain autoencoder. Sampling is a single decoder pass — orders of magnitude cheaper than diffusion, which is the main reason to still reach for a VAE.

Applications

How, where, and why it is used

Time-Series Forecasting

Adapted
How

Encode a window of the series into a latent distribution and decode the following window, so the forecast arrives as a distribution you can sample rather than a point. Sampling many decodings gives an empirical predictive interval directly.

Where
  • Probabilistic demand forecasting where the tail drives the inventory decision
  • Scenario generation for stress-testing a capacity or dispatch plan
Why

Reach for it only when the deliverable is genuinely a distribution — otherwise a quantile-loss LSTM gets you intervals for far less complexity. The real advantage is that samples are coherent whole trajectories rather than independent per-step quantiles, which matters when a downstream optimizer consumes the path.

Featurization
  • Condition the decoder on calendar and exogenous features, or samples ignore known structure
  • Scale per series before encoding; the Gaussian likelihood assumes comparable magnitudes
Evaluation

Continuous ranked probability score and pinball loss on rolling-origin splits — never point MSE, which rewards exactly the mean-seeking behaviour that makes VAE forecasts blurry.

Pitfalls
  • Posterior collapse produces samples that ignore the conditioning entirely and look plausible
  • The Gaussian likelihood over-smooths sharp transitions, so spikes get averaged away

Anomaly Detection

Viable
How

Train on normal data only, then score by reconstruction probability or by the ELBO itself. A point the model assigns low likelihood is one the learned distribution does not explain — which is a principled anomaly score rather than a heuristic distance.

Where
  • Manufacturing defect detection where defects are too rare and varied to label
  • Network intrusion detection scoring novelty against learned normal traffic
  • Medical imaging screening, flagging studies that do not look like the healthy distribution
Why

Better founded than a plain autoencoder: the score is a likelihood under an explicit probabilistic model, not an unnormalized error. The well-documented catch is that deep generative models can assign *higher* likelihood to out-of-distribution inputs than to training data, so the score needs validating on real anomalies rather than trusted on theory.

Featurization
  • Train exclusively on confirmed-normal data — contamination redefines normal
  • Keep the latent dimension tight; a wide bottleneck reconstructs anomalies too
  • Prefer importance-weighted likelihood over a single-sample ELBO for the score
Evaluation

PR-AUC and precision@k against confirmed anomalies, with the threshold taken from ELBO quantiles on a held-out clean set.

Pitfalls
  • Higher likelihood for OOD inputs is a real and documented failure of deep generative scoring
  • Blurry reconstructions inflate error on legitimately sharp normal inputs, causing false positives

Optimization

Not applicable

A VAE models a distribution and samples from it; it has no notion of a constraint, an objective to maximize over decisions, or a feasible region. Using one to propose solutions gives plausible-looking outputs with no optimality argument and no guarantee of validity — which in an optimization setting is worse than useless.

Also applied in

Computer Vision

Viable
How

Encode images to a compact latent and decode back, providing both a generative model and a learned representation for downstream tasks.

Where
  • Latent diffusion, where a VAE compresses pixels so the diffusion model can work in a smaller space
  • Image inpainting and attribute manipulation by moving along latent directions
Why

Rarely the final generator any more — diffusion beats it decisively on sample quality — but its compression stage is load-bearing inside modern latent diffusion systems, which is arguably a more important role than it ever had standalone.

Featurization
  • Convolutional encoder and decoder rather than dense layers; locality is the whole prior
  • Perceptual or adversarial reconstruction loss to counter Gaussian blur
Evaluation

FID for sample quality plus reconstruction PSNR, with human review — likelihood correlates poorly with perceived quality.

Pitfalls
  • Pixel-space Gaussian likelihood produces the characteristic blur
  • Latent-space interpolation looks smooth without implying the space is semantically disentangled

Causal Inference & Uplift

Adapted
How

Not for estimating an effect — for generating the scenarios an effect estimate is stress-tested against. Fit a VAE to observed covariates, sample synthetic populations under varied latent assumptions, and re-run the estimator to see how far the conclusion moves.

Where
  • Sensitivity analysis for unmeasured confounding, by simulating a plausible hidden confounder
  • Synthetic control-arm generation for power analysis before a trial is run
Why

The narrow legitimate use, and worth stating precisely because the tempting misuse is right next to it. A VAE trained on observational data has learned the confounding, not around it — asking it to produce counterfactuals reproduces the bias with generative confidence. Identification comes from the design; this only probes how fragile that design is.

Featurization
  • Condition on the treatment indicator so synthetic arms can be sampled separately
  • Preserve the covariate correlation structure — that is the whole object of interest
Evaluation

Whether the estimated effect survives across sampled scenarios, reported as a robustness range rather than a new point estimate.

Pitfalls
  • Treating generated counterfactuals as evidence — the model reproduces confounding, it does not remove it
  • Synthetic data inherits every bias in the observational sample that trained it

Risk & Fraud

Adapted
How

Two distinct uses: score novelty by likelihood, and synthesize minority-class examples to rebalance a severely skewed training set.

Where
  • Novelty scoring for fraud patterns never previously seen
  • Synthetic minority oversampling where SMOTE is too crude for the feature structure
Why

Useful where fraud is genuinely novel rather than a known pattern, since a supervised model can only recognize what it has been shown. Synthetic data must be validated hard, though — a VAE will happily generate artefacts the classifier then learns as signal.

Featurization
  • Mixed categorical and continuous features need per-type likelihoods, not one Gaussian
  • Fit on confirmed-legitimate transactions only
Evaluation

Downstream classifier lift from the synthetic data, plus PR-AUC of the likelihood score on its own.

Pitfalls
  • Synthetic samples can encode artefacts that the downstream model learns as real signal
  • Regulated decisions may not permit a score nobody can explain
Deployment

What it costs to run

Training

Hours on one GPU for typical tabular or modest image data — far cheaper than a diffusion model of comparable scope.

Inference

Sampling is a single decoder pass, so milliseconds. This is the VAE's standing advantage over diffusion, which needs tens to hundreds of passes per sample.

Retraining

Monthly to quarterly for generation. More often for anomaly scoring, where the definition of normal drifts.

Drift & monitoring
  • Track the KL term separately from the total ELBO — collapse is invisible in the sum
  • Monitor the ELBO distribution on known-normal traffic to catch threshold drift
  • Watch for latent dimensions that go permanently inactive; that is capacity you are paying for and not using
Production gotchas
  • Sample stochastically at inference or you get the posterior mean every time, which defeats the point
  • The likelihood is a bound, not a probability — do not present ELBO values as calibrated probabilities
  • Latent dimension and the normalization are part of the contract; changing either invalidates stored embeddings
Trade-offs

Pros and cons, in context

Strengths
  • Single-pass sampling

    Orders of magnitude cheaper than diffusion at inference, which is decisive for real-time or high-volume generation and irrelevant for offline batch work.

  • A principled likelihood-based anomaly score

    Gives a score grounded in an explicit probabilistic model rather than an unnormalized distance — though the OOD-likelihood pathology means it still has to be validated empirically.

  • Continuous, interpolable latent space

    What makes attribute manipulation and smooth interpolation work at all. A plain autoencoder's latent space has holes that decode to nothing.

  • Stable training

    A genuine advantage over GANs, which can collapse outright. The VAE's failure mode is subtler and quieter, which is its own problem.

Limitations
  • Blurry samples under a Gaussian likelihood

    Structural, not a tuning issue: the optimum under uncertainty is an average. Disqualifying for photorealistic generation, harmless for representation learning.

  • Posterior collapse

    The model quietly stops using the latent while the ELBO still looks fine. This is why the KL term must be monitored on its own, and why warm-up exists.

  • The objective is a bound, not the likelihood

    The gap is unmeasured, so ELBO values are not comparable across architectures and must never be reported as probabilities.

  • Can assign higher likelihood to out-of-distribution data

    A documented failure of deep generative scoring that directly undermines the anomaly-detection use case — validate on real anomalies rather than trusting the theory.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""A VAE forward pass and its ELBO - the two terms, written out.

Reconstruction pulls the latent toward encoding the input; the KL pulls it
toward the prior. The model that is useful sits between them.
"""

import math
import random


def encode(x, w_mu, w_logvar):
    """Returns the posterior mean and log-variance for one input."""
    mu = [sum(w * v for w, v in zip(row, x)) for row in w_mu]
    logvar = [sum(w * v for w, v in zip(row, x)) for row in w_logvar]
    return mu, logvar


def reparameterize(mu, logvar):
    """z = mu + sigma * eps, with eps sampled OUTSIDE the computation.

    This is the whole trick: the randomness becomes an input rather than an
    operation, so a gradient can flow through to mu and logvar.
    """
    z = []
    for m, lv in zip(mu, logvar):
        sigma = math.exp(0.5 * lv)
        eps = random.gauss(0.0, 1.0)
        z.append(m + sigma * eps)
    return z


def decode(z, w_out):
    return [sum(w * v for w, v in zip(row, z)) for row in w_out]


def elbo(x, x_hat, mu, logvar):
    """The bound, as two explicit terms. Returned to be MAXIMIZED."""
    # Gaussian log-likelihood, up to a constant: negative squared error.
    reconstruction = -sum((a - b) ** 2 for a, b in zip(x, x_hat))

    # Closed form for KL(N(mu, sigma) || N(0, I)) - no sampling needed.
    kl = 0.5 * sum(
        math.exp(lv) + m * m - 1.0 - lv for m, lv in zip(mu, logvar)
    )

    return reconstruction - kl

O(D·L) per sample in pure Python. One sample per gradient step, so the ELBO estimate is very noisy.

2. IdiomaticPyTorch

How this language actually wants it written.

"""VAE - typed, validated, with the KL warm-up that prevents collapse."""

from dataclasses import dataclass

import torch
from torch import Tensor, nn


@dataclass(frozen=True)
class VaeConfig:
    input_dim: int
    latent_dim: int = 32
    hidden_dim: int = 256
    beta: float = 1.0


class Vae(nn.Module):
    def __init__(self, config: VaeConfig) -> None:
        super().__init__()
        if config.latent_dim < 1:
            raise ValueError(f"latent_dim must be >= 1, got {config.latent_dim}")
        if config.beta < 0:
            raise ValueError(f"beta must be non-negative, got {config.beta}")

        self.config = config
        self.encoder = nn.Sequential(
            nn.Linear(config.input_dim, config.hidden_dim),
            nn.ReLU(),
        )
        # Two heads, not one: the posterior needs a mean and a spread.
        self.to_mu = nn.Linear(config.hidden_dim, config.latent_dim)
        self.to_logvar = nn.Linear(config.hidden_dim, config.latent_dim)
        self.decoder = nn.Sequential(
            nn.Linear(config.latent_dim, config.hidden_dim),
            nn.ReLU(),
            nn.Linear(config.hidden_dim, config.input_dim),
        )

    def encode(self, x: Tensor) -> tuple[Tensor, Tensor]:
        h = self.encoder(x)
        # log-variance rather than variance, so the output is unconstrained
        # and exp() guarantees positivity without a clamp.
        return self.to_mu(h), self.to_logvar(h)

    def forward(self, x: Tensor) -> tuple[Tensor, Tensor, Tensor]:
        if x.dim() != 2:
            raise ValueError(f"expected (batch, features), got {tuple(x.shape)}")
        mu, logvar = self.encode(x)
        z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu)
        return self.decoder(z), mu, logvar


def elbo_loss(
    x: Tensor, x_hat: Tensor, mu: Tensor, logvar: Tensor, beta: float
) -> tuple[Tensor, Tensor]:
    """Returns (negative ELBO to minimize, KL term for monitoring).

    The KL is returned separately on purpose: posterior collapse is invisible
    in the total, and the total is what people watch.
    """
    reconstruction = nn.functional.mse_loss(x_hat, x, reduction="sum") / x.size(0)
    kl = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp()) / x.size(0)
    return reconstruction + beta * kl, kl


def kl_weight(epoch: int, warmup_epochs: int, beta: float) -> float:
    """Anneal the KL from 0 to beta. Without this the KL wins early, the
    encoder collapses to the prior, and it never recovers."""
    if warmup_epochs <= 0:
        return beta
    return beta * min(1.0, epoch / warmup_epochs)

What changed: The hand-rolled passes become framework modules, log-variance replaces variance so positivity is structural rather than clamped, and the two failure modes that actually kill VAEs in practice are addressed directly: the KL is returned separately so collapse is visible, and warm-up stops it happening in the first place.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

Two network passes per batch, fully batched on GPU.

3. OptimizedPyTorch

Faster, and the justification for each change.

"""VAE - fused KL, multi-sample estimator, one pass over the batch."""

import torch
from torch import Tensor, nn


def elbo_fused(x: Tensor, x_hat: Tensor, mu: Tensor, logvar: Tensor, beta: float) -> Tensor:
    """Both ELBO terms in a single fused reduction.

    The naive form builds five intermediate tensors (logvar.exp(), mu.pow(2),
    the sum, the negation, the scale). torch.addcmul folds the square and the
    accumulate into one traversal, and the whole expression reduces in one pass.
    """
    batch = x.size(0)
    recon = (x_hat - x).pow_(2).sum() / batch

    # -0.5 * sum(1 + logvar - mu^2 - exp(logvar)), computed without temporaries.
    kl_terms = torch.addcmul(logvar.exp() - logvar - 1.0, mu, mu)
    kl = 0.5 * kl_terms.sum() / batch

    return recon + beta * kl


def importance_weighted_elbo(model: nn.Module, x: Tensor, num_samples: int = 8) -> Tensor:
    """A tighter bound from K latent samples, for anomaly scoring.

    Expanding the batch to (K*B, D) evaluates all K samples in ONE decoder
    pass rather than K sequential ones - the difference between an 8x cost and
    roughly a 1.2x one, since the decoder is compute-bound and under-occupied
    at typical batch sizes.
    """
    batch, dim = x.shape
    mu, logvar = model.encode(x)

    # Contiguous expansion so the decoder sees one dense batch, not a strided view.
    mu_rep = mu.repeat_interleave(num_samples, dim=0)
    logvar_rep = logvar.repeat_interleave(num_samples, dim=0)
    x_rep = x.repeat_interleave(num_samples, dim=0)

    z = mu_rep + torch.exp(0.5 * logvar_rep) * torch.randn_like(mu_rep)
    x_hat = model.decoder(z)

    log_w = -(x_hat - x_rep).pow(2).sum(dim=1)
    log_w = log_w.view(batch, num_samples)

    # logsumexp in float32 regardless of autocast dtype: the whole point of
    # the estimator is the tail, and the tail is where float16 underflows.
    return torch.logsumexp(log_w.float(), dim=1) - torch.log(
        torch.tensor(float(num_samples))
    )

What changed: Two changes. The ELBO reduction is fused so the five intermediate tensors of the naive expression collapse into one traversal. And the multi-sample estimator — which is what makes the anomaly score usable — expands the batch once so all K samples go through the decoder in a single pass instead of K sequential ones.

  • Pre-allocate output arrays and use in-place operations

    pow_ and addcmul fold the squaring and accumulation into existing buffers, removing five intermediate tensors from a reduction that runs every step.

    Trade-off: In-place ops mutate their input, so an autograd graph that needs the original value will error — and the message points at the wrong line.

  • Batch work to amortize interpreter overhead

    repeat_interleave evaluates all K importance samples in one decoder pass; the decoder is under-occupied at typical batch sizes, so K samples cost far less than K times.

    Trade-off: Peak memory scales with K·B — at K=64 this is what will exhaust the GPU, not the model.

  • Ensure contiguous memory layout and a single dtype

    repeat_interleave produces a contiguous tensor the decoder can consume without a gather, and the float32 logsumexp keeps the tail from underflowing under mixed precision.

    Trade-off: The contiguous expansion is a real copy of K·B·D floats rather than a free stride trick.

One fused reduction per step; K samples in one decoder pass. Illustrative, not a measured benchmark.