Part III: Discovery Through Data and Models
Chapter 30: Anomaly and Novelty Discovery

30.2 Deep Anomaly Detection

"I compressed your entire dataset into 16 dimensions and then tried to reconstruct it. The things I could not reconstruct are either anomalies or things I was too lazy to learn."

An Autoencoder With Imposter Syndrome

Prerequisites

Section 30.1 covered statistical and geometric anomaly detection methods for tabular data. Neural networks extend those ideas to high-dimensional structured data. You should be familiar with basic neural network training (forward pass, backpropagation, loss functions) from Chapter 26: Representation Learning. Understanding latent spaces and dimensionality reduction from Chapter 25 will help motivate why autoencoders work for anomaly detection.

The Big Picture

A telescope surveys ten million stars per night, each described by a spectrum with a thousand wavelength channels; somewhere in that torrent, a handful of spectra encode physics no one has seen before. The methods of Section 30.1 struggle with data at this scale: kernel density estimation (KDE) suffers from the curse of dimensionality, local outlier factor (LOF) distance computations become unreliable in many dimensions, and even Isolation Forest's random splits become less informative as feature counts grow into the hundreds or thousands. Deep anomaly detection solves this by learning a compressed representation of "normal" data, then scoring new observations by how well they fit that learned representation. As Figure 30.3 illustrates, three families of models dominate: autoencoders (score by reconstruction error), variational autoencoders (score by likelihood under a learned generative model), and normalizing flows (score by exact log-likelihood). Each offers a different trade-off between flexibility, interpretability, and computational cost.

Autoencoder VAE Normalizing Flow Input x Encoder fϕ Latent z Decoder gθ Recon. x̂ Score: ||x - x̂||² Input x Encoder q(z|x) μ, σ² → z Decoder p(x|z) Recon. x̂ Score: neg. ELBO Input x Layer f₁ Layer f₂ ... Layer fₖ z ~ N(0,I) Score: neg. log p(x)
Figure 30.3: Three deep anomaly detection architectures compared. The autoencoder (left) scores by reconstruction error. The VAE (center) outputs a distribution over latent codes and scores by the negative ELBO, combining reconstruction with latent-space regularity. The normalizing flow (right) chains invertible layers to map data to a base Gaussian distribution, providing an exact log-likelihood anomaly score.

1. Autoencoders for Anomaly Detection

In 2019, a faulty sensor in a semiconductor fab went undetected for three weeks because the plant's threshold-based monitors could not parse the high-dimensional sensor traces fast enough to spot the drift. The resulting wafer defects reportedly cost millions. Autoencoders compress exactly these kinds of complex signals into a form where deviations become obvious, catching what static rules miss.

An autoencoder copies its input to its output through a narrow internal bottleneck. This bottleneck forces the network to discover compact features that capture the essence of the training data. Any input whose structure differs from the training data maps poorly onto those learned features, producing a measurable reconstruction gap. The encoder compresses input into fewer dimensions, and the decoder rebuilds the full input from that compressed code. Prefer autoencoders over classical methods (such as Isolation Forest or LOF) when your data is high-dimensional or structured (images, spectra, sequences). They also scale to millions of observations without storing the training set in memory.

More formally, an autoencoder learns two functions: an encoder \(f_\phi: \mathbb{R}^D \to \mathbb{R}^d\) that maps high-dimensional input \(x\) to a low-dimensional latent code \(z = f_\phi(x)\), and a decoder \(g_\theta: \mathbb{R}^d \to \mathbb{R}^D\) that reconstructs the input from the code \(\hat{x} = g_\theta(z)\). Training minimizes the reconstruction loss:

$$\mathcal{L}_{\text{AE}} = \frac{1}{n} \sum_{i=1}^{n} \|x_i - g_\theta(f_\phi(x_i))\|^2$$

Train the autoencoder on normal data only. The bottleneck learns a compact representation of normal patterns and allocates no capacity to anomalous ones, so anomalous inputs reconstruct poorly. The reconstruction error \(\|x - \hat{x}\|^2\) serves directly as an anomaly score: the larger the gap between input and reconstruction, the less the input resembles the training distribution. In short: if the network cannot rebuild it, the network has never learned anything like it. Figure 30.2.1 illustrates the autoencoder anomaly detection pipeline.

Autoencoder anomaly detection pipeline
Figure 30.2.1: How an autoencoder detects anomalies through reconstruction error: normal inputs pass through the bottleneck and reconstruct faithfully (low error), while anomalous inputs that differ from learned patterns reconstruct poorly (high error), producing a usable anomaly score.

Common Misconception

A frequent misconception is that autoencoders will always produce high reconstruction error for anomalies. In practice, if the anomalous data shares low-level structure with normal data (for example, similar textures or frequency components), the autoencoder may reconstruct anomalies well enough to miss them entirely. Reconstruction error only catches anomalies that differ from normal data in ways the bottleneck is forced to discard; anomalies that lie within the subspace the autoencoder has already learned will pass through undetected.

import torch
import torch.nn as nn
import numpy as np

class AnomalyAutoencoder(nn.Module):
    """Autoencoder for anomaly detection with configurable architecture."""

    def __init__(self, input_dim: int, latent_dim: int = 16, hidden_dims: list = None):
        super().__init__()
        if hidden_dims is None:
            hidden_dims = [64, 32]

        # Build encoder
        encoder_layers = []
        prev_dim = input_dim
        for h_dim in hidden_dims:
            encoder_layers.extend([
                nn.Linear(prev_dim, h_dim),
                nn.ReLU(),
                nn.BatchNorm1d(h_dim),
            ])
            prev_dim = h_dim
        encoder_layers.append(nn.Linear(prev_dim, latent_dim))
        self.encoder = nn.Sequential(*encoder_layers)

        # Build decoder (mirror of encoder)
        decoder_layers = []
        prev_dim = latent_dim
        for h_dim in reversed(hidden_dims):
            decoder_layers.extend([
                nn.Linear(prev_dim, h_dim),
                nn.ReLU(),
                nn.BatchNorm1d(h_dim),
            ])
            prev_dim = h_dim
        decoder_layers.append(nn.Linear(prev_dim, input_dim))
        self.decoder = nn.Sequential(*decoder_layers)

    def forward(self, x):
        z = self.encoder(x)
        x_hat = self.decoder(z)
        return x_hat, z

    def anomaly_score(self, x):
        """Reconstruction error as anomaly score."""
        self.eval()
        with torch.no_grad():
            x_hat, _ = self.forward(x)
            scores = torch.mean((x - x_hat) ** 2, dim=1)
        return scores


def train_autoencoder(model, X_train, epochs=100, lr=1e-3, batch_size=64):
    """Train autoencoder on normal data only."""
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    dataset = torch.utils.data.TensorDataset(torch.FloatTensor(X_train))
    loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)

    model.train()
    for epoch in range(epochs):
        total_loss = 0
        for (batch,) in loader:
            x_hat, _ = model(batch)
            loss = nn.functional.mse_loss(x_hat, batch)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total_loss += loss.item() * len(batch)
        if (epoch + 1) % 25 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Loss: {total_loss/len(X_train):.6f}")
    return model


# Example: synthetic spectral data (100 wavelength channels)
rng = np.random.default_rng(42)
n_normal, n_anomaly, n_features = 1000, 50, 100

# Normal spectra: smooth Gaussian-like profiles
X_normal = np.zeros((n_normal, n_features))
for i in range(n_normal):
    center = rng.uniform(30, 70)
    width = rng.uniform(5, 15)
    X_normal[i] = np.exp(-0.5 * ((np.arange(n_features) - center) / width) ** 2)
    X_normal[i] += rng.normal(0, 0.02, n_features)  # measurement noise

# Anomalous spectra: double-peaked or sharp emission lines
X_anomaly = np.zeros((n_anomaly, n_features))
for i in range(n_anomaly):
    c1, c2 = rng.uniform(20, 45), rng.uniform(55, 80)
    X_anomaly[i] = 0.5 * np.exp(-0.5 * ((np.arange(n_features) - c1) / 3) ** 2)
    X_anomaly[i] += 0.5 * np.exp(-0.5 * ((np.arange(n_features) - c2) / 3) ** 2)
    X_anomaly[i] += rng.normal(0, 0.02, n_features)

# Train on normal data only (novelty detection setup)
model = AnomalyAutoencoder(input_dim=n_features, latent_dim=8, hidden_dims=[64, 32])
model = train_autoencoder(model, X_normal, epochs=100, lr=1e-3)

# Score both normal and anomalous data
X_all = np.vstack([X_normal, X_anomaly])
labels = np.array([0]*n_normal + [1]*n_anomaly)
scores = model.anomaly_score(torch.FloatTensor(X_all)).numpy()

print(f"\nMean score (normal): {scores[labels==0].mean():.6f}")
print(f"Mean score (anomaly): {scores[labels==1].mean():.6f}")
print(f"Ratio: {scores[labels==1].mean() / scores[labels==0].mean():.1f}x")
Listing 30.5: A complete autoencoder for anomaly detection on synthetic spectral data, trained only on normal single-peaked spectra and evaluated on both normal and anomalous double-peaked spectra.
Epoch 25/100, Loss: 0.000892
Epoch 50/100, Loss: 0.000531
Epoch 75/100, Loss: 0.000478
Epoch 100/100, Loss: 0.000452

Mean score (normal): 0.000461
Mean score (anomaly): 0.012847
Ratio: 27.9x
Output of Listing 30.5: anomalous double-peaked spectra produce reconstruction errors 28 times larger than normal single-peaked spectra, confirming clear separation between the two classes.
Key Insight: The Bottleneck Is the Anomaly Detector

The latent dimension \(d\) controls the sensitivity of anomaly detection. Too large (close to the input dimension \(D\)), and the autoencoder can memorize even anomalous patterns, producing low reconstruction error for everything. Too small, and normal data is reconstructed poorly, creating noise that obscures real anomalies. A practical guideline: set \(d\) to capture 90-95% of the variance in normal data (calibrate with principal component analysis (PCA) on the training set as a reference). The bottleneck is doing the same job as the bandwidth in KDE or the number of neighbors in LOF: controlling the resolution at which "different" becomes "anomalous."

2. Variational Autoencoders (VAEs) for Anomaly Scoring

Reconstruction error is a useful anomaly signal, but it tells us nothing about how probable an observation is under a learned model of normality; upgrading from a point estimate to a full probability distribution over latent codes addresses exactly that gap.

Standard autoencoders produce anomaly scores via reconstruction error, but they lack a principled probability model. VAEs (Kingma & Welling, 2014) fix this by learning both an encoder \(q_\phi(z|x)\) that maps inputs to a distribution over latent codes and a decoder \(p_\theta(x|z)\) that generates data from codes. The training objective is the evidence lower bound (ELBO), where the ELBO is a tractable lower bound on the log-likelihood \(\log p(x)\) that can be optimized by gradient descent:

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

The first term encourages faithful reconstruction (as in standard autoencoders). The second term, the Kullback-Leibler (KL) divergence, pushes the learned posterior \(q_\phi(z|x)\) toward the prior \(p(z) = \mathcal{N}(0, I)\), regularizing the latent space to be smooth and continuous. Because the encoder outputs a distribution rather than a fixed point, sampling from it during training requires the reparameterization trick: instead of sampling \(z\) directly from \(q_\phi(z|x)\), we compute \(z = \mu + \sigma \odot \epsilon\) where \(\epsilon \sim \mathcal{N}(0, I)\), which allows gradients to flow back through \(\mu\) and \(\sigma\) during backpropagation. For anomaly detection, the VAE provides two complementary scores:

Reconstruction probability: \(\log p_\theta(x|z)\) evaluated at the mean of \(q_\phi(z|x)\). Low reconstruction probability indicates the model cannot generate data that looks like the input.

ELBO score: the full ELBO combines reconstruction and regularization. An input that both reconstructs poorly and maps to an unusual region of latent space receives a particularly low ELBO, making this a more robust anomaly score than reconstruction error alone.

In practice, a hyperparameter \(\beta\) is often used to weight the KL term relative to the reconstruction term, giving the loss \(\mathcal{L} = \text{recon} + \beta \cdot D_{\text{KL}}\). Setting \(\beta < 1\) prioritizes reconstruction quality (better anomaly separation by reconstruction error), while \(\beta > 1\) enforces a more structured latent space (better anomaly separation by latent-space distance). This trade-off, known as the \(\beta\)-VAE framework, lets you tune whether the anomaly score relies more on reconstruction fidelity or on latent-space regularity.

class VAEAnomalyDetector(nn.Module):
    """Variational autoencoder for probabilistic anomaly scoring."""

    def __init__(self, input_dim: int, latent_dim: int = 8, hidden_dim: int = 64):
        super().__init__()
        # Encoder: outputs mean and log-variance of q(z|x)
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim // 2), nn.ReLU(),
        )
        self.fc_mu = nn.Linear(hidden_dim // 2, latent_dim)
        self.fc_logvar = nn.Linear(hidden_dim // 2, latent_dim)

        # Decoder: p(x|z)
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim, hidden_dim // 2), nn.ReLU(),
            nn.Linear(hidden_dim // 2, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, input_dim),
        )
        self.latent_dim = latent_dim

    def encode(self, x):
        h = self.encoder(x)
        return self.fc_mu(h), self.fc_logvar(h)

    def reparameterize(self, mu, logvar):
        """Reparameterization trick: sample z = mu + sigma * epsilon,
        where epsilon ~ N(0,I), so gradients flow through mu and sigma."""
        std = torch.exp(0.5 * logvar)
        eps = torch.randn_like(std)
        return mu + eps * std

    def forward(self, x):
        mu, logvar = self.encode(x)
        z = self.reparameterize(mu, logvar)
        x_hat = self.decoder(z)
        return x_hat, mu, logvar

    def elbo_anomaly_score(self, x, n_samples: int = 10):
        """Negative ELBO as anomaly score (higher = more anomalous)."""
        self.eval()
        with torch.no_grad():
            mu, logvar = self.encode(x)
            # Monte Carlo estimate of reconstruction term
            recon_loss = 0
            for _ in range(n_samples):
                z = self.reparameterize(mu, logvar)
                x_hat = self.decoder(z)
                recon_loss += torch.sum((x - x_hat) ** 2, dim=1)
            recon_loss /= n_samples
            # KL divergence: closed form for Gaussian
            kl_div = -0.5 * torch.sum(1 + logvar - mu**2 - logvar.exp(), dim=1)
            return recon_loss + kl_div  # negative ELBO


def train_vae(model, X_train, epochs=150, lr=1e-3, batch_size=64, beta=1.0):
    """Train VAE with optional beta-weighting on KL term."""
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    dataset = torch.utils.data.TensorDataset(torch.FloatTensor(X_train))
    loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)

    model.train()
    for epoch in range(epochs):
        total_loss = 0
        for (batch,) in loader:
            x_hat, mu, logvar = model(batch)
            recon = nn.functional.mse_loss(x_hat, batch, reduction='sum')
            kl = -0.5 * torch.sum(1 + logvar - mu**2 - logvar.exp())
            loss = recon + beta * kl
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
        if (epoch + 1) % 50 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Loss: {total_loss/len(X_train):.4f}")
    return model


# Train VAE on normal spectra
vae = VAEAnomalyDetector(input_dim=n_features, latent_dim=8, hidden_dim=64)
vae = train_vae(vae, X_normal, epochs=150, lr=1e-3, beta=0.5)

# Score with ELBO
vae_scores = vae.elbo_anomaly_score(torch.FloatTensor(X_all), n_samples=20).numpy()
print(f"\nVAE ELBO score (normal): {vae_scores[labels==0].mean():.4f}")
print(f"VAE ELBO score (anomaly): {vae_scores[labels==1].mean():.4f}")
print(f"Separation ratio: {vae_scores[labels==1].mean() / vae_scores[labels==0].mean():.1f}x")
Listing 30.6: A VAE anomaly detector scoring observations via the negative ELBO, combining reconstruction quality with KL divergence to penalize inputs that both reconstruct poorly and land in unusual latent regions.
Practical Example: VAEs for Anomalous Galaxy Detection

Astronomers at the University of Portsmouth trained a convolutional VAE on Galaxy Zoo images of "normal" galaxies (ellipticals and spirals). When applied to unlabeled survey images, the highest-ELBO-score objects turned out to include gravitational lenses, galaxy mergers, and tidal streams: rare phenomena that citizen scientists had previously needed years to identify manually. The VAE's latent space also provided a continuous similarity metric: galaxies near the boundary between normal and anomalous latent regions were "mildly unusual" objects that merited further investigation. This graduated scoring, rather than a binary normal/anomalous decision, is one of the VAE's key advantages over reconstruction-error-only methods.

3. Normalizing Flows for Exact Likelihood

The ELBO that VAEs optimize is, by definition, a lower bound on the true log-likelihood; the gap between the bound and the exact value means that some anomalies may receive scores that are off by an unknown margin. Normalizing flows close that gap entirely.

Both autoencoders and VAEs provide proxy scores for anomalies (reconstruction error or ELBO bound). Normalizing flows (Rezende & Mohamed, 2015), where a "flow" is a chain of invertible neural network layers that together define a bijective mapping between data space and latent space, offer something stronger: exact computation of the log-likelihood \(\log p(x)\) under a learned generative model. If \(\log p(x)\) is low, the observation is genuinely improbable under the model, which is among the most principled definitions of an anomaly.

A normalizing flow defines an invertible transformation \(f: \mathbb{R}^D \to \mathbb{R}^D\) that maps a simple base distribution \(p_z(z) = \mathcal{N}(0, I)\) to the complex data distribution \(p_x(x)\). By the change of variables formula:

$$\log p_x(x) = \log p_z(f^{-1}(x)) + \log \left|\det \frac{\partial f^{-1}}{\partial x}\right|$$

Mental Model

Normalizing flow as a taffy-pulling machine that reversibly stretches a simple ball into a complex shape

Think of a normalizing flow like a taffy-pulling machine. You start with a simple, uniform ball of taffy (the Gaussian base distribution) and apply a series of precise stretches, folds, and twists (the invertible transformations) until the taffy takes a complex final shape that matches your data. Because every stretch is reversible, you can take any piece of the finished taffy and trace it back through every step to find exactly where it came from in the original ball. Points that trace back to the dense center of the original ball are common; points that trace back to the far edge are rare. The Jacobian determinant, where the Jacobian is the matrix of all partial derivatives of the transformation, accounts for how much each stretch thinned or thickened the taffy at that spot, letting you compute exact density everywhere.

The first term measures how likely the transformed point is under the base distribution. The second term (the log-determinant of the Jacobian) accounts for how the transformation stretches or compresses local volume. Training maximizes the total log-likelihood over the training data. At test time, low \(\log p_x(x)\) indicates an anomaly.

class RealNVPCoupling(nn.Module):
    """Single coupling layer for a RealNVP normalizing flow.

    RealNVP (Real-valued Non-Volume Preserving) splits dimensions into
    two groups: one group passes through unchanged, while the other is
    scaled and translated by neural networks conditioned on the first group.
    """

    def __init__(self, dim: int, hidden_dim: int = 64, mask_type: str = 'even'):
        super().__init__()
        self.dim = dim
        # Binary mask: which dimensions are transformed vs. passed through
        if mask_type == 'even':
            self.register_buffer('mask', torch.arange(dim).float() % 2)
        else:
            self.register_buffer('mask', 1 - torch.arange(dim).float() % 2)

        # Scale and translate networks
        self.scale_net = nn.Sequential(
            nn.Linear(dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, dim), nn.Tanh(),  # bounded scale
        )
        self.translate_net = nn.Sequential(
            nn.Linear(dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, dim),
        )

    def forward(self, x):
        """Forward: data -> latent. Returns transformed x and log-det-jacobian."""
        x_masked = x * self.mask
        s = self.scale_net(x_masked) * (1 - self.mask)
        t = self.translate_net(x_masked) * (1 - self.mask)
        y = x_masked + (1 - self.mask) * (x * torch.exp(s) + t)
        log_det = s.sum(dim=1)
        return y, log_det

    def inverse(self, y):
        """Inverse: latent -> data."""
        y_masked = y * self.mask
        s = self.scale_net(y_masked) * (1 - self.mask)
        t = self.translate_net(y_masked) * (1 - self.mask)
        x = y_masked + (1 - self.mask) * (y - t) * torch.exp(-s)
        return x


class RealNVPFlow(nn.Module):
    """RealNVP normalizing flow for density estimation and anomaly detection."""

    def __init__(self, dim: int, n_layers: int = 6, hidden_dim: int = 64):
        super().__init__()
        self.layers = nn.ModuleList()
        for i in range(n_layers):
            mask_type = 'even' if i % 2 == 0 else 'odd'
            self.layers.append(RealNVPCoupling(dim, hidden_dim, mask_type))
        self.dim = dim

    def forward(self, x):
        """Map data to latent space, accumulating log-det-jacobian."""
        log_det_total = torch.zeros(x.shape[0], device=x.device)
        z = x
        for layer in self.layers:
            z, log_det = layer(z)
            log_det_total += log_det
        return z, log_det_total

    def log_prob(self, x):
        """Exact log-likelihood under the flow model."""
        z, log_det = self.forward(x)
        # Log-prob under base distribution (standard normal)
        log_pz = -0.5 * (z**2 + np.log(2 * np.pi)).sum(dim=1)
        return log_pz + log_det

    def anomaly_score(self, x):
        """Negative log-likelihood as anomaly score."""
        self.eval()
        with torch.no_grad():
            return -self.log_prob(x)


def train_flow(model, X_train, epochs=200, lr=1e-3, batch_size=64):
    """Train normalizing flow by maximum likelihood."""
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    dataset = torch.utils.data.TensorDataset(torch.FloatTensor(X_train))
    loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True)

    model.train()
    for epoch in range(epochs):
        total_nll = 0
        for (batch,) in loader:
            nll = -model.log_prob(batch).mean()  # negative log-likelihood
            optimizer.zero_grad()
            nll.backward()
            optimizer.step()
            total_nll += nll.item() * len(batch)
        if (epoch + 1) % 50 == 0:
            print(f"Epoch {epoch+1}/{epochs}, NLL: {total_nll/len(X_train):.4f}")
    return model


# Train flow on normal spectra
flow = RealNVPFlow(dim=n_features, n_layers=8, hidden_dim=64)
flow = train_flow(flow, X_normal, epochs=200, lr=5e-4)

# Score all data with exact log-likelihood
flow_scores = flow.anomaly_score(torch.FloatTensor(X_all)).numpy()
print(f"\nFlow NLL (normal): {flow_scores[labels==0].mean():.2f}")
print(f"Flow NLL (anomaly): {flow_scores[labels==1].mean():.2f}")
Listing 30.7: A RealNVP normalizing flow for anomaly detection via exact log-likelihood, using alternating coupling layers with even/odd dimension masks to build an invertible mapping from data space to a standard Gaussian. (As of 2024, flow matching (Lipman et al., 2023) and continuous normalizing flows have largely supplanted coupling-layer designs like RealNVP for new projects, offering simpler training and better density estimates; the RealNVP architecture remains a clear pedagogical starting point.)
Key Insight: Likelihood Alone Is Not Enough

A surprising result from Nalisnick et al. (2019) showed that normalizing flows trained on CIFAR-10 assign higher likelihood to SVHN images (a completely different dataset) than to held-out CIFAR-10 images. This "typicality" problem arises because likelihood measures proximity to the model's density mode, not membership in the training distribution. In high dimensions, typical samples from a distribution lie on a thin shell far from the mode, while out-of-distribution samples can accidentally land near the mode. Practical solutions include using the likelihood ratio between a foreground model (trained on your data) and a background model (trained on a generic corpus), or combining likelihood with a complexity measure. We address this further in the OOD detection section below.

Choosing Between Autoencoder, VAE, and Normalizing Flow

All three architectures learn what "normal" looks like, but they differ in what they give back. Autoencoders are the simplest to implement and train; use them when you need a fast, interpretable anomaly score and your data is high-dimensional but not so complex that you need a full density model. VAEs add a probabilistic latent space, giving you both a reconstruction score and a measure of how unusual the latent encoding is; choose them when you want graduated anomaly severity or when you plan to generate synthetic normal examples for downstream tasks. Normalizing flows provide exact log-likelihood, the most principled score, but require invertible architectures that constrain model design and increase training cost; use them when you need calibrated probability estimates or when the typicality problem (addressed via likelihood ratios) is manageable for your domain. In practice, start with an autoencoder as a baseline, upgrade to a VAE if reconstruction error alone gives poor separation, and reserve flows for settings where exact density matters.

4. Out-of-Distribution Detection for Neural Networks

The generative methods above learn what normal data looks like and flag deviations; but in supervised settings, we face a complementary problem: a trained classifier that encounters data entirely outside its training domain may still produce a confident (and meaningless) prediction.

Out-of-distribution (OOD) detection, where "out-of-distribution" means the input comes from a different data-generating process than the one represented in the training set, addresses a specific failure mode of supervised neural networks: producing confident predictions on inputs that bear no relation to the training data. A classifier trained on chest X-rays should not return a confident diagnosis when given a photograph of a cat, yet standard softmax classifiers (where the softmax function converts raw network outputs into a probability distribution that sums to one) will do exactly that. OOD detection adds a safety layer that flags inputs the model should refuse to classify.

Post-hoc Detection Scores

The Maximum Softmax Probability (MSP) baseline (Hendrycks & Gimpel, 2017) uses the maximum of the softmax output as a confidence score. In-distribution inputs tend to produce high MSP; OOD inputs tend to produce lower MSP, though the separation is often poor. ODIN (Liang et al., 2018) improves on MSP with two modifications. First, it computes softmax at temperature \(T > 1\), spreading the distribution and amplifying differences. Second, it perturbs inputs in the direction that increases the maximum softmax probability. This perturbation benefits in-distribution inputs more than OOD inputs, widening the gap between the two.

More recent methods operate in the feature space rather than the output space. Mahalanobis distance (Lee et al., 2018) fits a class-conditional Gaussian to the penultimate layer features and scores inputs by their Mahalanobis distance (a distance metric that accounts for correlations between features by weighting differences through the inverse covariance matrix) to the nearest class centroid. Energy-based scoring (Liu et al., 2020) uses the negative log of the partition function (the normalizing constant in the denominator of the softmax, which sums the exponentiated logits across all classes) as a theoretically grounded OOD score:

$$E(x) = -\log \sum_{c=1}^{C} \exp(f_c(x))$$

where \(f_c(x)\) is the logit (the raw, unnormalized output of the network's final layer before the softmax transformation) for class \(c\). Low energy (high partition function) indicates in-distribution; high energy indicates OOD. (As of 2024, newer post-hoc OOD scorers such as Virtual-logit Matching (ViM; Wang et al., 2022), KNN-based detection (Sun et al., 2022), and Activation Shaping (ASH; Djurisic et al., 2023) often outperform energy scoring on standard benchmarks while remaining equally simple to apply to a pretrained classifier.)

Checkpoint

So far: OOD detection scores a pretrained classifier's outputs to flag inputs from outside the training distribution, using either output-space methods (MSP, ODIN) that examine softmax probabilities, or feature-space methods (Mahalanobis distance, energy scoring) that analyze the network's internal representations for signs of unfamiliarity.

import torch.nn.functional as F

def energy_score(logits: torch.Tensor, temperature: float = 1.0) -> torch.Tensor:
    """Energy-based OOD score: -T * log(sum(exp(logits/T))).

    Lower energy = more likely in-distribution.
    Higher energy = more likely OOD.
    """
    return -temperature * torch.logsumexp(logits / temperature, dim=1)


def mahalanobis_ood_score(features: torch.Tensor,
                          class_means: list[torch.Tensor],
                          precision: torch.Tensor) -> torch.Tensor:
    """Mahalanobis distance to nearest class centroid in feature space.

    Args:
        features: (N, D) feature vectors from penultimate layer
        class_means: list of (D,) class centroid vectors
        precision: (D, D) shared precision matrix (inverse covariance)
    Returns:
        (N,) minimum Mahalanobis distance across classes
    """
    min_distances = torch.full((features.shape[0],), float('inf'))
    for mu in class_means:
        diff = features - mu.unsqueeze(0)  # (N, D)
        # Mahalanobis: sqrt(diff @ precision @ diff^T), squared for efficiency
        m_dist = torch.sum(diff @ precision * diff, dim=1)
        min_distances = torch.minimum(min_distances, m_dist)
    return min_distances


# Example: comparing MSP and energy scores on a trained classifier
# Assume 'classifier' is a trained model, 'in_loader' and 'ood_loader' are data loaders
# (Pseudocode showing the evaluation pattern)
"""
in_energies, ood_energies = [], []
for x, _ in in_loader:
    logits = classifier(x)
    in_energies.append(energy_score(logits))
for x, _ in ood_loader:
    logits = classifier(x)
    ood_energies.append(energy_score(logits))

in_energies = torch.cat(in_energies)
ood_energies = torch.cat(ood_energies)
# AUROC: how well does the energy score separate in-dist from OOD?
auroc = compute_auroc(in_energies, ood_energies)
"""
Listing 30.8: Energy-based and Mahalanobis OOD scoring functions that operate on a pretrained classifier's logits or penultimate-layer features, adding an OOD safety layer without retraining.
Right Tool: OpenOOD Benchmark

The OpenOOD framework (Yang et al., 2022) provides a standardized benchmark for evaluating OOD detection methods. It includes implementations of MSP, ODIN, Mahalanobis, energy scoring, and 20+ other methods, along with curated in-distribution/OOD dataset pairs. What would take hundreds of lines of custom evaluation code reduces to:

from openood.evaluators import OODEvaluator

evaluator = OODEvaluator(model, id_data='cifar10', ood_data=['svhn', 'textures'])
results = evaluator.evaluate(methods=['msp', 'energy', 'mahalanobis'])
# Returns AUROC, FPR@95TPR, and detection accuracy for each method
Listing 30.9: Using OpenOOD to evaluate MSP, energy, and Mahalanobis OOD detectors on CIFAR-10 with SVHN and Textures as OOD test sets.

OpenOOD handles data loading, feature extraction, metric computation, and cross-method comparison in under 10 lines. (As of 2024, OpenOOD v1.5 has expanded to cover over 40 methods, including ViM, KNN, and ASH; check the repository for current API signatures, as the evaluator interface has evolved since the original 2022 release.)

Research Frontier: Foundation Model OOD Detection

The rise of foundation models (Chapter 27) creates new OOD challenges. Models like CLIP and ESM-2 are trained on massive, diverse datasets, making it unclear what "in-distribution" even means. Sun et al. (2024) introduced CIDER (Compactness and Dispersion regularization), a method that fine-tunes vision-language model embeddings so that in-distribution classes form tight clusters while remaining well separated, enabling OOD detection without curating an explicit outlier dataset. CIDER achieved state-of-the-art results on the large-scale ImageNet-1k OOD benchmark, reporting improvements over prior zero-shot approaches by up to 4.1% area under the ROC curve (AUROC). For scientific foundation models, the pattern is similar: Zhang et al. (2023) applied contrastive OOD scoring to protein language models (ESM-2) for detecting out-of-family protein sequences, using the distance between a query embedding and its nearest class prototype as a calibrated novelty signal. These methods connect directly to the Bayesian uncertainty framework of Chapter 32.

Fun Note: The Alien Signal Problem

In 2015, astronomer Tabetha Boyajian reported an anomalous light curve for the star KIC 8462852 (now called "Boyajian's Star") that no standard model could explain. The signal showed irregular, non-periodic dips of up to 22%, far exceeding what any known exoplanet transit could produce. An autoencoder trained on normal Kepler light curves would have flagged it immediately: reconstruction error off the charts. The anomaly spawned dozens of hypotheses, from cometary swarms to alien megastructures. The leading explanation today is a cloud of dust and debris, but the case illustrates how anomaly detection in astronomical data can produce both genuine scientific puzzles and wildly entertaining speculation.

Try It: Autoencoder Anomaly Detector on MNIST Digits

Build a working deep anomaly detector in under 30 minutes using only PyTorch and matplotlib. (1) Load the MNIST dataset via torchvision.datasets.MNIST and filter the training set to contain only images of a single digit (for example, digit "1"). Flatten each 28x28 image to a 784-dimensional vector. (2) Build a simple autoencoder with encoder layers [784, 128, 32] and a symmetric decoder [32, 128, 784], using ReLU activations. Train it for 30 epochs on the single-digit training set using mean squared error (MSE) loss and the Adam optimizer with learning rate 1e-3. (3) Compute reconstruction error (per-sample MSE) on held-out images of the trained digit and on images of a different digit (for example, digit "7"). (4) Plot overlapping histograms of the reconstruction errors for the two groups and observe the separation. (5) Compute the AUROC score using sklearn.metrics.roc_auc_score, treating the trained digit as "normal" (label 0) and the other digit as "anomalous" (label 1). You should typically see AUROC above 0.95, confirming that the autoencoder reliably distinguishes digits it was trained on from digits it has never seen.

Exercise 30.2.1

You train an autoencoder with a 32-dimensional latent space on 100-dimensional normal spectra. The model achieves a mean reconstruction error of 0.001 on the training set, but when you evaluate it on held-out normal data, the mean reconstruction error is 0.045, and on anomalous data it is 0.052. The anomaly separation ratio is only 1.15x. What is the most likely cause, and what concrete change would you make to fix it?

Hint

Compare the latent dimension (32) to the intrinsic dimensionality of the data. If the bottleneck is too wide relative to the complexity of normal patterns, the autoencoder can memorize the training set (low training error) without learning generalizable features (high validation error for both normal and anomalous inputs). Consider what happens if you reduce the latent dimension to, say, 8 or 4, and retrain.

Step-Through: VAE Anomaly Scoring

Trace through the VAE ELBO anomaly score for a single 4-dimensional input \(x = [1.0, 0.5, 0.2, 0.1]\) with a trained VAE whose encoder outputs \(\mu = [0.3, -0.1]\) and \(\log\sigma^2 = [-1.0, -0.5]\) (latent dim = 2).

Step 1: Reparameterize. Compute \(\sigma = [\exp(-0.5), \exp(-0.25)] = [0.607, 0.779]\). Draw \(\epsilon = [0.2, -0.3]\) from \(\mathcal{N}(0,1)\). Then \(z = \mu + \sigma \odot \epsilon = [0.3 + 0.607 \times 0.2,\; -0.1 + 0.779 \times (-0.3)] = [0.421, -0.334]\).

Step 2: Decode. Suppose the decoder produces \(\hat{x} = [0.95, 0.48, 0.25, 0.13]\). Reconstruction loss \(= (1.0-0.95)^2 + (0.5-0.48)^2 + (0.2-0.25)^2 + (0.1-0.13)^2 = 0.0025 + 0.0004 + 0.0025 + 0.0009 = 0.0063\).

Step 3: KL divergence. \(D_{\text{KL}} = -0.5 \sum (1 + \log\sigma^2 - \mu^2 - \sigma^2)\). Per dimension: $[-0.5(1 + (-1.0) - 0.09 - 0.368), -0.5(1 + (-0.5) - 0.01 - 0.607)]$ $= [-0.5(-0.458), -0.5(-0.117)] = [0.229, 0.059]$. Total KL \(= 0.288\).

Step 4: ELBO score. Negative ELBO \(= 0.0063 + 0.288 = 0.294\). A normal input might score around 0.05; this input's score of 0.294 would place it well into the anomalous range, with the KL term (indicating an unusual latent position) contributing most of the signal.

Real-World Application: Particle Physics at CERN

The ATLAS experiment at CERN's Large Hadron Collider uses variational autoencoders to detect anomalous collision events in real time. The VAE is trained on simulated Standard Model background events (the "normal" distribution), and collision events with high reconstruction error or low ELBO are flagged for further analysis as potential signatures of new physics. This approach supplements traditional trigger systems by catching unexpected event topologies that hand-designed filters would miss.

Lab: Comparing Deep Anomaly Detectors on Fashion-MNIST

Goal: Empirically compare autoencoder, VAE, and normalizing flow anomaly detection on image data to observe their different failure modes and strengths.

Tools: PyTorch, torchvision (for Fashion-MNIST), scikit-learn (for AUROC), matplotlib (for visualization). No GPU required; CPU training completes in under 10 minutes per model.

Setup (15 min): Load Fashion-MNIST via torchvision.datasets.FashionMNIST. Train each of the three models (autoencoder, VAE, RealNVP flow) on a single "normal" class (for example, class 0: T-shirt). Use flattened 784-dimensional vectors, latent dimension 16, and 50 training epochs for each model.

What to vary: (1) Change which class is "normal" (try sneakers vs. pullovers) and observe how AUROC changes across anomaly classes. (2) Vary the latent dimension from 2 to 64 and plot AUROC as a function of bottleneck width for each model. (3) For the VAE, sweep \(\beta\) from 0.01 to 10 and track how the reconstruction/KL trade-off affects anomaly separation.

What to observe: Which model achieves the highest AUROC on "easy" anomalies (structurally very different classes) versus "hard" anomalies (visually similar classes)? Does the normalizing flow exhibit the typicality problem (assigning higher likelihood to some OOD classes than to held-out normal data)? At what latent dimension does each model start to lose anomaly discrimination?

Exercises

  1. (Conceptual) An autoencoder trained on normal electrocardiogram (ECG) signals achieves low reconstruction error on atrial fibrillation episodes. Explain why this might happen (hint: consider the latent dimension and training data diversity). How would you modify the architecture or training procedure to improve anomaly detection for this case?
  2. (Coding) Implement all three deep anomaly detectors (autoencoder, VAE, normalizing flow) on the PyOD cardio dataset. Compare their AUROC scores and training times. Which method provides the best score separation? Plot the score distributions for normal and anomalous classes for each method.
  3. (Analysis) The "typicality" problem (Nalisnick et al., 2019) shows that high likelihood does not guarantee in-distribution. Implement a likelihood ratio test by training two normalizing flows: one on your target data and one on a generic reference dataset. Show that the ratio \(\log p_{\text{target}}(x) - \log p_{\text{reference}}(x)\) provides better OOD separation than raw likelihood alone. Use synthetic data to construct a clear demonstration.

What's Next

The methods in this section assume a stationary world: normal data has a fixed distribution, and anomalies deviate from it. But in real scientific instruments and long-running experiments, the definition of "normal" itself changes over time. In Section 30.3: Drift and Open-World Learning, we tackle distribution drift, where yesterday's anomaly becomes today's baseline, and open-world learning, where entirely new categories of observations appear after deployment.

Bibliography

Kingma, D. P. & Welling, M. (2014). Auto-encoding variational Bayes. Proc. ICLR.

The foundational VAE paper enabling probabilistic anomaly scoring through learned latent distributions.

Rezende, D. J. & Mohamed, S. (2015). Variational inference with normalizing flows. Proc. ICML.

Introduced normalizing flows for flexible variational inference and density estimation.

Nalisnick, E., Matsukawa, A., Teh, Y. W., Gorur, D., & Lakshminarayanan, B. (2019). Do deep generative models know what they don't know? Proc. ICLR.

The influential paper demonstrating that generative model likelihood can be higher for OOD data, motivating likelihood-ratio and typicality-based approaches.

Liu, W., Wang, X., Owens, J., & Li, Y. (2020). Energy-based out-of-distribution detection. NeurIPS, 33.

Energy-based OOD scoring, providing a theoretically grounded alternative to softmax confidence.