AI Knowledge Tree

Normalizing flows

Use invertible neural networks with tractable Jacobian determinants to model exact likelihoods.

⏱ 26 minPrerequisites:Transformations of random variables

A normalizing flow transforms a simple base distribution (a Gaussian) into the data distribution through a sequence of invertible neural network layers. Because each layer is invertible and has a tractable Jacobian determinant, the exact log-likelihood of any data point can be computed via the change-of-variables formula[1]. This makes flows a clean middle ground between VAEs (likelihood lower bound) and GANs (no likelihood).

The change-of-variables formula

For an invertible f:Rd→Rdf: \mathbb{R}^d \to \mathbb{R}^d and z=f−1(x)z = f^{-1}(x),

pX(x)=pZ(z) ∣det⁡∂f−1∂x∣=pZ(f−1(x)) ∣det⁡∂f∂z∣−1.p_X(x) = p_Z(z)\, \left|\det \frac{\partial f^{-1}}{\partial x}\right| = p_Z(f^{-1}(x))\, \left|\det \frac{\partial f}{\partial z}\right|^{-1}.

If ff is a composition f=fL∘⋯∘f1f = f_L \circ \cdots \circ f_1 of invertible layers, the log-likelihood becomes a sum:

log⁡pX(x)=log⁡pZ(z0)−∑ℓ=1Llog⁡ ⁣∣det⁡∂fℓ∂zℓ−1∣.\log p_X(x) = \log p_Z(z_0) - \sum_{\ell=1}^L \log\!\left|\det \frac{\partial f_\ell}{\partial z_{\ell-1}}\right|.

Train by maximizing ∑ilog⁡pX(xi)\sum_i \log p_X(x_i): a clean negative log-likelihood that gives exact density estimates.

The design challenge

Two competing requirements:

  • Expressiveness: each layer must be flexible enough to compose into rich distributions.
  • Tractability: each layer must be invertible and have a computable Jacobian determinant.

The full Jacobian is d×dd \times d (dd in the thousands for images), so computing its determinant naively is O(d3)O(d^3). Flow architectures use special structures that make the determinant cheap.

Coupling layers (RealNVP)

Dinh et al. (2017). Split the input into two halves x=(xa,xb)x = (x_a, x_b) and apply[2]:

ya=xa,yb=xb⊙exp⁡(s(xa))+t(xa).y_a = x_a, \qquad y_b = x_b \odot \exp(s(x_a)) + t(x_a).

The first half passes through unchanged; the second half is elementwise-scaled and shifted by functions of the first half. The inverse is

xa=ya,xb=(yb−t(ya))⊙exp⁡(−s(ya)).x_a = y_a, \qquad x_b = (y_b - t(y_a)) \odot \exp(-s(y_a)).

The Jacobian is triangular, so its determinant is the product of the diagonal entries: det⁡=∏iexp⁡(s(xa)i)\det = \prod_i \exp(s(x_a)_i). The log determinant is just ∑si\sum s_i, computed in O(d)O(d).

That single number log⁡∣det⁡J∣=∑isi\log|\det J| = \sum_i s_i is the local volume change the layer applies, and reading it on a scale makes the change-of-variables bookkeeping concrete:

  • ∑si=0\sum s_i = 0: the layer is locally volume-preserving, factor e0=1e^0 = 1. A unit cube of latent space maps to a unit cube of data space, density unchanged.
  • ∑si=+2\sum s_i = +2: the map stretches volume by e2≈7.4×e^2 \approx 7.4\times. Density spreads over 7.47.4 times the volume, so pXp_X drops by that factor where the flow expands.
  • ∑si=−2\sum s_i = -2: the map compresses volume to e−2≈0.135×e^{-2} \approx 0.135\times. Probability piles up, density rises about 7.47.4-fold.

A flow builds a sharp data distribution out of a smooth Gaussian by accumulating these per-layer volume changes; the closing code's stacked layers report log-dets in the range [−0.28, 0.34][-0.28,\ 0.34] for this small affine example, each a near-unit volume tweak that compounds across the stack.

Stack many coupling layers, alternating which half is "active", and you get an expressive flow. RealNVP was the first practical normalizing-flow generative model.

Other flow architectures

  • Glow (Kingma & Dhariwal, 2018): RealNVP plus invertible 1×1 convolutions and ActNorm. Improved image generation.
  • Autoregressive flows (MAF, IAF): yiy_i depends only on x<ix_{<i} via a masked autoregressive network. Triangular Jacobian, tractable.
  • Spline flows: replace affine couplings with monotonic spline transformations. More expressive per layer.
  • Continuous flows (NeuralODE, FFJORD): parameterize the transformation as the solution of an ODE; use adjoint methods for gradients. Foundation for flow matching (later D4 lesson).

What flows are good at

  • Exact likelihood: no variational bound, no MCMC. Useful for density estimation, anomaly detection, model comparison.
  • Invertibility: encode and decode any point, useful for latent manipulation.
  • Stable training: maximum likelihood is a simple, well-conditioned objective.

What they are not great at

  • Sample quality: at the same parameter count, flows produce worse images than VAEs, GANs, or diffusion. The architectural constraint (invertibility) is restrictive.
  • High-dimensional efficiency: the invertibility requirement keeps the data and latent dimensions equal, so you cannot compress.

Flows are a poor choice for state-of-the-art image generation but remain useful for:

  • Density estimation in scientific applications (physics, biology).
  • Variational inference (use a flow as the variational posterior).
  • Building blocks in larger pipelines (e.g., flow-based VAE posteriors).
  • The conceptual foundation for flow matching (D4 final lesson), which dropped the invertibility constraint and became state-of-the-art.
Stacked invertible coupling layers warp a simple Gaussian into a complex shape; because every step is invertible with a known Jacobian, the exact density transfers with it.
Stacked invertible coupling layers warp a simple Gaussian into a complex shape; because every step is invertible with a known Jacobian, the exact density transfers with it.

Check it on arrays

python · numpy
import numpy as np

rng = np.random.default_rng(0)

# RealNVP-style coupling layer in 1D pairs
def coupling_forward(x, s_fn, t_fn):
    """Split x in half, transform the second half conditioned on the first."""
    d = x.shape[1]
    h = d // 2
    x_a = x[:, :h]; x_b = x[:, h:]
    s = s_fn(x_a); t = t_fn(x_a)
    y_a = x_a
    y_b = x_b * np.exp(s) + t
    log_det = s.sum(axis=1)
    return np.column_stack([y_a, y_b]), log_det

def coupling_inverse(y, s_fn, t_fn):
    d = y.shape[1]
    h = d // 2
    y_a = y[:, :h]; y_b = y[:, h:]
    s = s_fn(y_a); t = t_fn(y_a)
    x_a = y_a
    x_b = (y_b - t) * np.exp(-s)
    return np.column_stack([x_a, x_b])

# Simple s and t: linear functions of the active half
d = 4
W_s = rng.normal(scale=0.3, size=(d // 2, d // 2))
W_t = rng.normal(scale=0.3, size=(d // 2, d // 2))
def s_fn(x): return np.tanh(x @ W_s)                  # bounded for stability
def t_fn(x): return x @ W_t

# Forward and inverse should round-trip
X = rng.normal(size=(10, d))
Y, log_det = coupling_forward(X, s_fn, t_fn)
X_back = coupling_inverse(Y, s_fn, t_fn)
print(f"forward-inverse roundtrip error: {np.max(np.abs(X - X_back)):.2e}")
print(f"log |det J| per sample: {log_det[:5].round(3)}")

# Stack multiple coupling layers, alternating the split (here just two)
def stack_forward(x, layers):
    logdet_total = np.zeros(len(x))
    for s, t in layers:
        x, ld = coupling_forward(x, s, t)
        logdet_total += ld
        # swap halves
        x = np.column_stack([x[:, d//2:], x[:, :d//2]])
    return x, logdet_total

layers = [(s_fn, t_fn), (s_fn, t_fn), (s_fn, t_fn)]
Z, log_det = stack_forward(X, layers)

# log p(x) = log p(z) + log|det df^{-1}/dx|  (stack_forward is the data->latent map)
log_pz = -0.5 * np.sum(Z**2, axis=1) - 0.5 * d * np.log(2 * np.pi)
log_px = log_pz + log_det
print(f"log p(x) for a few samples: {log_px[:5].round(3)}")

Each coupling layer is invertible and contributes a cheap log-Jacobian to the total log-density.

Exercises

Exercise 1. A RealNVP coupling layer sets ya=xay_a = x_a and yb=xb⊙exp⁡(s(xa))+t(xa)y_b = x_b \odot \exp(s(x_a)) + t(x_a). Show that the Jacobian ∂y/∂x\partial y / \partial x is lower triangular and that log⁡∣det⁡J∣=∑is(xa)i\log|\det J| = \sum_i s(x_a)_i.

Solution

Step 1. Order the coordinates as (xa,xb)(x_a, x_b) and write the Jacobian in 2×22\times 2 block form, where each block is a partial of an output half by an input half:

J=(∂ya∂xa∂ya∂xb∂yb∂xa∂yb∂xb).J = \begin{pmatrix} \dfrac{\partial y_a}{\partial x_a} & \dfrac{\partial y_a}{\partial x_b} \\[1.2em] \dfrac{\partial y_b}{\partial x_a} & \dfrac{\partial y_b}{\partial x_b} \end{pmatrix}.

Step 2. Compute the blocks. Since ya=xay_a = x_a, the top-left block is II and the top-right is 00 (because yay_a does not depend on xbx_b). For the bottom row, yb=xb⊙exp⁡(s(xa))+t(xa)y_b = x_b \odot \exp(s(x_a)) + t(x_a), so ∂yb/∂xb=diag⁡(exp⁡(s(xa)))\partial y_b / \partial x_b = \operatorname{diag}(\exp(s(x_a))) and ∂yb/∂xa\partial y_b / \partial x_a is some block BB (its value does not matter):

J=(I0Bdiag⁡(exp⁡(s(xa)))).J = \begin{pmatrix} I & 0 \\ B & \operatorname{diag}(\exp(s(x_a))) \end{pmatrix}.

Step 3. With a zero top-right block, JJ is block lower triangular, so its determinant is the product of the diagonal block determinants:

det⁡J=det⁡(I)⋅det⁡ ⁣(diag⁡(exp⁡(s(xa))))=∏iexp⁡(s(xa)i).\det J = \det(I)\cdot \det\!\big(\operatorname{diag}(\exp(s(x_a)))\big) = \prod_i \exp(s(x_a)_i).

Step 4. Take the log: log⁡∣det⁡J∣=∑is(xa)i\log|\det J| = \sum_i s(x_a)_i. The off-diagonal block BB never enters, which is why the coupling layer's log-determinant costs only O(d)O(d) regardless of how complex ss and tt are.

Exercise 2. A flow stacks layers f=fL∘⋯∘f1f = f_L \circ \cdots \circ f_1. Starting from the single-step change-of-variables formula, derive the additive log-density log⁡pX(x)=log⁡pZ(zL)+∑ℓ=1Llog⁡∣det⁡∂fℓ/∂zℓ−1∣\log p_X(x) = \log p_Z(z_L) + \sum_{\ell=1}^L \log|\det \partial f_\ell / \partial z_{\ell-1}|, where z0=xz_0 = x is mapped to zLz_L in the base space.

Solution

Step 1. For one invertible map zℓ=fℓ(zℓ−1)z_\ell = f_\ell(z_{\ell-1}), the change-of-variables formula relates the densities of input and output:

pℓ−1(zℓ−1)=pℓ(zℓ) ∣det⁡∂fℓ∂zℓ−1∣.p_{\ell-1}(z_{\ell-1}) = p_\ell(z_\ell)\,\left|\det \frac{\partial f_\ell}{\partial z_{\ell-1}}\right|.

Step 2. Take logs of each step: log⁡pℓ−1(zℓ−1)=log⁡pℓ(zℓ)+log⁡∣det⁡∂fℓ/∂zℓ−1∣\log p_{\ell-1}(z_{\ell-1}) = \log p_\ell(z_\ell) + \log|\det \partial f_\ell/\partial z_{\ell-1}|. Rearranged, log⁡pℓ−1=log⁡pℓ−(−log⁡∣det⁡⋯∣)\log p_{\ell-1} = \log p_\ell - (-\log|\det\cdots|); keep the form

log⁡pℓ−1(zℓ−1)=log⁡pℓ(zℓ)+log⁡∣det⁡∂fℓ∂zℓ−1∣.\log p_{\ell-1}(z_{\ell-1}) = \log p_\ell(z_\ell) + \log\left|\det \frac{\partial f_\ell}{\partial z_{\ell-1}}\right|.

Step 3. Chain from ℓ=1\ell = 1 (where z0=xz_0 = x, p0=pXp_0 = p_X) up to ℓ=L\ell = L (where pL=pZp_L = p_Z, the base). Summing the telescoping log-densities,

log⁡pX(x)=log⁡pZ(zL)+∑ℓ=1Llog⁡∣det⁡∂fℓ∂zℓ−1∣.\log p_X(x) = \log p_Z(z_L) + \sum_{\ell=1}^L \log\left|\det \frac{\partial f_\ell}{\partial z_{\ell-1}}\right|.

Step 4. This is the stated formula: the base density is evaluated at the fully transformed point zLz_L, and each layer contributes one additive log⁡∣det⁡∂fℓ/∂zℓ−1∣\log|\det \partial f_\ell / \partial z_{\ell-1}| term. The total log-Jacobian of a stack is the sum of per-layer log-Jacobians.

Exercise 3 (code). Implement a 1-D affine flow z=f(x)=(x−t) e−sz = f(x) = (x - t)\,e^{-s} that maps data to a standard normal base, with scalar parameters s,ts, t. Use change of variables to evaluate log⁡pX(x)\log p_X(x) and confirm it integrates to a proper density by checking it matches the implied Gaussian N(t,e2s)\mathcal{N}(t, e^{2s}) numerically.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(0)

s, t = 0.4, 1.5                          # flow params: scale e^s, shift t

def f(x):                                # data -> base
    return (x - t) * np.exp(-s)

def log_px(x):
    z = f(x)
    log_pz = -0.5 * z**2 - 0.5 * np.log(2 * np.pi)   # N(0,1) base
    log_det = -s                                      # d/dx of (x - t) e^{-s}
    return log_pz + log_det

# f pushes data to N(0,1), so X = t + e^s * Z is N(t, e^{2s})
xs = np.linspace(-4, 7, 1000)
implied_mu, implied_sigma = t, np.exp(s)
log_gauss = -0.5 * ((xs - implied_mu) / implied_sigma)**2 - np.log(implied_sigma * np.sqrt(2 * np.pi))

print(f"max |log_px - log N(t, e^2s)|: {np.max(np.abs(log_px(xs) - log_gauss)):.2e}")

# numerical normalization: density integrates to 1
px = np.exp(log_px(xs))
integral = np.trapz(px, xs)
print(f"integral of p_X over the grid: {integral:.4f}")

assert np.allclose(log_px(xs), log_gauss, atol=1e-10)
assert np.isclose(integral, 1.0, atol=1e-3)

The change-of-variables density matches the Gaussian N(t,e2s)\mathcal{N}(t, e^{2s}) the affine map implies, and it integrates to 11: the log⁡∣det⁡J∣=−s\log|\det J| = -s term is exactly the volume correction that keeps the transformed density normalized.

The next lesson covers the generative method that took over from GANs: diffusion models.


References

  1. [1]Rezende, D. J., & Mohamed, S. (2015). "Variational inference with normalizing flows". ICML. arXiv:1505.05770. The normalizing-flow framework via the change-of-variables formula.↩
  2. [2]Dinh, L., Sohl-Dickstein, J., & Bengio, S. (2017). "Density estimation using Real NVP". ICLR. arXiv:1605.08803. Affine coupling layers with triangular Jacobians.↩