The Fokker–Planck Equation and Probability Flow

Dive into Deep Learning · §28.3

The cloud’s law of motion
Fokker–Planck, the probability-flow ODE, and the reverse SDE.

One PDE rules the cloud

Motivation

One SDE path is jagged and unrepeatable. The cloud of paths is a density p_t(\mathbf x) that evolves deterministically.

  • Drift transports it, diffusion smooths it.
  • Two payoffs: a deterministic twin ODE, and a reverse SDE.
  • Both need one unknown: the score \nabla\log p_t.
image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/

01

From paths to densities

the two viewpoints and the vector-calculus toolkit

One particle, or the whole cloud

Two viewpoints

The Lagrangian view follows one realization; the Eulerian view tracks the time-marginal p_t(\mathbf x), which averages over all noise and is therefore reproducible. A hundred thousand OU walkers land on the analytic Gaussians:

t=0.1: cloud mean +1.8121 (analytic +1.8097), std 0.4275 (analytic 0.4258)
t=0.5: cloud mean +1.2137 (analytic +1.2131), std 0.7987 (analytic 0.7951)
t=2.0: cloud mean +0.2750 (analytic +0.2707), std 0.9889 (analytic 0.9908)

Three facts from vector calculus

The toolkit

  • Divergence \nabla\cdot\mathbf v: net flux per unit volume.
  • Laplacian \Delta h: how far h sits below its neighborhood average.
  • By parts \int h\,(\nabla\cdot\mathbf v) = -\int \nabla h\cdot\mathbf v.

These three identities are the entire machinery behind everything that follows.

02

The Fokker–Planck equation

derivation, term reading, the OU check

Itô + by parts → a PDE

Derivation

Take a test function \phi: Itô gives d\phi=(\nabla\phi\cdot\mathbf f+\tfrac12 g^2\Delta\phi)dt+\text{noise}. Take \mathbb E[\cdot]=\int(\cdot)\,p_t, kill the Itô term, and integrate by parts onto p_t:

\partial_t p_t = -\nabla\cdot(\mathbf f\,p_t) + \tfrac12 g(t)^2\,\Delta p_t.

For \mathbf f = -\nabla V this PDE is itself a gradient flow: steepest descent of the free energy \int pV + \tfrac12 g^2\!\int p\log p in the Wasserstein geometry (Jordan–Kinderlehrer–Otto), the same transport geometry that powers flow matching.

Drift transports, diffusion smooths

Reading the PDE

-\nabla\cdot(\mathbf f p) streams probability along the drift; \tfrac12 g^2\Delta p raises p wherever it dips below its neighborhood. With \mathbf f=\mathbf 0 it is the heat equation, variance growing linearly.

image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/

The Ornstein–Uhlenbeck check

A closed-form solution

A Gaussian \mathcal N(m(t),v(t)) solves the OU Fokker–Planck iff

\dot m = -\theta m, \qquad \dot v = \sigma^2 - 2\theta v

(match coefficients of (x-m)^0,(x-m)^1,(x-m)^2). The steady state gives \mathcal N(0,\sigma^2/2\theta); numerically the residual is tiny:

def ou_density(x, t, x0=2.0):
    m, v = ou_mean_var(x0, t)
    return np.exp(-(x - m)**2 / (2 * v)) / np.sqrt(2 * np.pi * v)

xs = np.linspace(-4.0, 5.0, 4001)
dx = xs[1] - xs[0]
for t in ts_show:
    eps = 1e-5
    dpdt = (ou_density(xs, t + eps) - ou_density(xs, t - eps)) / (2 * eps)
    p = ou_density(xs, t)
    transport = np.gradient(theta * xs * p, dx)        # -d/dx(f p), f = -theta x
    diffusion = 0.5 * sigma**2 * np.gradient(np.gradient(p, dx), dx)
    residual = (dpdt - (transport + diffusion))[5:-5]  # trim one-sided edge stencils
    print(f't={t}: max|residual| = {np.abs(residual).max():.2e}, '
          f'max|dp/dt| = {np.abs(dpdt).max():.2e}')
t=0.1: max|residual| = 1.34e-04, max|dp/dt| = 4.82e+00
t=0.5: max|residual| = 4.56e-06, max|dp/dt| = 5.40e-01
t=2.0: max|residual| = 1.08e-06, max|dp/dt| = 6.70e-02

03

Continuity and the probability-flow ODE

conservation, diffusion as transport, the deterministic twin

Probability is conserved

Continuity

For a deterministic velocity \dot{\mathbf x}=\mathbf v, mass changes only through boundary flux:

\partial_t q_t + \nabla\cdot(q_t\mathbf v) = 0.

image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/

Along a path, \tfrac{d}{dt}\log q_t = -\nabla\cdot\mathbf v, the flow’s log-density rule.

Diffusion is transport in disguise

The key identity

Because \nabla p = p\,\nabla\log p,

\tfrac12 g^2\,\Delta p = \nabla\cdot\!\bigl(p\,\bigl[+\tfrac12 g^2\,\nabla\log p\bigr]\bigr).

Sign trap: standalone it carries a plus; the minus appears only after folding into continuity form. The wrong sign misses by a factor of two:

p = ou_density(xs, 0.5)
lhs = 0.5 * sigma**2 * np.gradient(np.gradient(p, dx), dx)
score_fd = np.gradient(np.log(p), dx)
rhs = np.gradient(p * (0.5 * sigma**2 * score_fd), dx)      # correct: + sign
wrong = np.gradient(p * (-0.5 * sigma**2 * score_fd), dx)   # sign-flipped
trim = slice(5, -5)                                         # drop edge stencils
print(f'max|lhs| = {np.abs(lhs[trim]).max():.3e}')
print(f'max|lhs - rhs|   (plus sign)  = {np.abs((lhs - rhs)[trim]).max():.3e}')
print(f'max|lhs - wrong| (minus sign) = {np.abs((lhs - wrong)[trim]).max():.3e}')
max|lhs| = 7.938e-01
max|lhs - rhs|   (plus sign)  = 3.179e-06
max|lhs - wrong| (minus sign) = 1.588e+00

The probability-flow ODE

The deterministic twin

Folding the identity into Fokker–Planck gives a continuity equation with velocity

\frac{d\mathbf x}{dt} = \mathbf f(\mathbf x,t) - \tfrac12 g(t)^2\,\nabla\log p_t(\mathbf x).

This ODE has the same time-marginals as the SDE, by uniqueness of the linear transport PDE. With the exact score and divergence, it is smooth, invertible, and has an exact likelihood identity.

One cloud, two dynamics

The experiment

Same marginals, different motion: SDE paths cross and rattle, ODE trajectories glide and never cross. The clouds are statistically indistinguishable (\mathrm{KS} < 0.009):

t=0.25: KS(SDE cloud, ODE cloud) = 0.0065
t=1.0: KS(SDE cloud, ODE cloud) = 0.0086
t=3.0: KS(SDE cloud, ODE cloud) = 0.0081

04

The score function

geometry, worked forms, normalizer-invariance

The one unknown

The score

\mathbf s_t(\mathbf x)=\nabla_{\mathbf x}\log p_t(\mathbf x) points uphill on the log-density and vanishes at modes. Gaussian: -(\mathbf x-\boldsymbol\mu)/\sigma^2, a spring to the mean; a mixture gives responsibility-weighted springs.

image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/

Why scores beat densities

Normalizer-invariance

For p=\tilde p/Z, \nabla\log p = \nabla\log\tilde p since \nabla\log Z = 0. An energy model p\propto e^{-E} has score -\nabla E, no intractable Z:

gs = np.linspace(-3.5, 3.5, 1401)
dg = gs[1] - gs[0]
s_resp = score_t(gs, 0.0)                       # responsibility formula
s_tanh = (2.0 * np.tanh(2.0 * gs / 0.0625) - gs) / 0.0625   # closed form
print(f'max|responsibility - tanh form| = {np.abs(s_resp - s_tanh).max():.2e}')

zeros = gs[:-1][np.sign(s_resp[:-1]) != np.sign(s_resp[1:])]
print(f'score vanishes near: {np.unique(np.round(zeros, 2) + 0.0)}')

s_unnorm = np.gradient(np.log(7.3 * p_t(gs, 0.0)), dg)      # density scaled by 7.3
s_norm = np.gradient(np.log(p_t(gs, 0.0)), dg)
print(f'max|score(7.3 p) - score(p)| = {np.abs(s_unnorm - s_norm).max():.2e}')

dens = p_t(gs, 0.0)
d2l.plot(gs, [s_resp, dens * (np.abs(s_resp).max() / dens.max())], 'x', '',
         legend=['score s(x)', 'density (rescaled)'], figsize=(5.5, 3))

max|responsibility - tanh form| = 5.15e-14
score vanishes near: [-2.  0.  2.]
max|score(7.3 p) - score(p)| = 3.55e-13

A network can represent the score without ever normalizing, which is why score matching works where density estimation fails.

05

Time reversal

Anderson’s theorem and the factor of two

Anderson’s reverse-time SDE

Time reversal

Running the diffusion backward from the terminal Gaussian:

d\mathbf X = \bigl[\mathbf f - g(t)^2\,\nabla\log p_t(\mathbf X)\bigr]dt + g(t)\,d\bar{\mathbf W}.

image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/

A Bayesian Euler step shows why: the marginal p_t acts as a prior that bends each reversed step toward where the data was.

The factor of two, drawn

Time reversal

One dial covers every sampler: \mathbf b_\lambda = \mathbf f - \tfrac{1+\lambda^2}{2}g^2\nabla\log p_t with noise \lambda g, all sharing the marginals p_t. More injected noise, more drift correction: the factor slides from \tfrac12 g^2 (\lambda=0, the PF-ODE) to g^2 (\lambda=1, Anderson):

Noise back into data

Generation

Zero training: the closed-form mixture score re-grows both modes from noise; swap in a trained network and this loop is a diffusion model:

max|p_T - N(0,1)| at T=3: 0.0015  (vs peak 0.3974)
mass split: 0.500 / 0.500  (target 0.5 / 0.5)
left mode:  mean -1.999, std 0.260  (target -2.000, 0.250)
right mode: mean +2.001, std 0.261  (target +2.000, 0.250)
KS(generated, exact p0 sample) = 0.0172

Recap

Wrap-up

  • Fokker–Planck, \partial_t p = -\nabla\cdot(\mathbf f p)+\tfrac12 g^2\Delta p: drift transports, diffusion smooths.
  • Transport identity (plus sign!) makes it a continuity equation.
  • PF-ODE: same marginals as the SDE, deterministic, exact likelihood identity for the exact score and divergence.
  • Score \nabla\log p_t: normalizer-free, points to data, learnable.
  • Reverse SDE drift corrects by g^2\nabla\log p_t, twice the ODE’s.
  • The single unknown in all three: the score.

Next: learn that score via score matching, diffusion, and flow matching.