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.
- 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
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
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.
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)
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.
How, where, and why it is used
Time-Series Forecasting
AdaptedEncode 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.
- Probabilistic demand forecasting where the tail drives the inventory decision
- Scenario generation for stress-testing a capacity or dispatch plan
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.
- Condition the decoder on calendar and exogenous features, or samples ignore known structure
- Scale per series before encoding; the Gaussian likelihood assumes comparable magnitudes
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.
- 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
ViableTrain 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.
- 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
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.
- 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
PR-AUC and precision@k against confirmed anomalies, with the threshold taken from ELBO quantiles on a held-out clean set.
- 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 applicableA 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.
Computer Vision
ViableEncode images to a compact latent and decode back, providing both a generative model and a learned representation for downstream tasks.
- 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
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.
- Convolutional encoder and decoder rather than dense layers; locality is the whole prior
- Perceptual or adversarial reconstruction loss to counter Gaussian blur
FID for sample quality plus reconstruction PSNR, with human review — likelihood correlates poorly with perceived quality.
- Pixel-space Gaussian likelihood produces the characteristic blur
- Latent-space interpolation looks smooth without implying the space is semantically disentangled
Causal Inference & Uplift
AdaptedNot 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.
- Sensitivity analysis for unmeasured confounding, by simulating a plausible hidden confounder
- Synthetic control-arm generation for power analysis before a trial is run
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.
- Condition on the treatment indicator so synthetic arms can be sampled separately
- Preserve the covariate correlation structure — that is the whole object of interest
Whether the estimated effect survives across sampled scenarios, reported as a robustness range rather than a new point estimate.
- 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
AdaptedTwo distinct uses: score novelty by likelihood, and synthesize minority-class examples to rebalance a severely skewed training set.
- Novelty scoring for fraud patterns never previously seen
- Synthetic minority oversampling where SMOTE is too crude for the feature structure
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.
- Mixed categorical and continuous features need per-type likelihoods, not one Gaussian
- Fit on confirmed-legitimate transactions only
Downstream classifier lift from the synthetic data, plus PR-AUC of the likelihood score on its own.
- Synthetic samples can encode artefacts that the downstream model learns as real signal
- Regulated decisions may not permit a score nobody can explain
What it costs to run
Hours on one GPU for typical tabular or modest image data — far cheaper than a diffusion model of comparable scope.
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.
Monthly to quarterly for generation. More often for anomaly scoring, where the definition of normal drifts.
- 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
- 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
Pros and cons, in context
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.
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.
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.
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 - klO(D·L) per sample in pure Python. One sample per gradient step, so the ELBO estimate is very noisy.
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.
Two network passes per batch, fully batched on GPU.
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.