What it actually does
Stop thinking about a thousand discrete denoising steps and think about a continuous path from noise to data. At any point along that path there is a direction that leads toward the data distribution — the score, or equivalently a velocity — and a network can be trained to predict it by regression. Once you have that direction field, generating a sample is solving a differential equation, which means every numerical-integration technique becomes available. Flow matching then asks the obvious follow-up: if the path is ours to choose, why not choose a straight one? A straight path can be traversed in very few large steps, which is the whole reason modern samplers need dozens rather than thousands.
- The data is continuous, so a continuous-time transport between noise and data is meaningful
- One network can represent the velocity or score across every time along the path
- The learned field is smooth enough that a large-step solver stays on the trajectory — which is what path straightness buys
- The sampling parameterization matches the training one, since they are affine transforms and mixing them is silent
- Likelihood, where used, is a meaningful measure of typicality — false in high dimensions, exactly as for normalizing flows
Loss function
Classified as a plain loss rather than a likelihood or a bound, and that classification is the point. What is optimized is an ordinary regression against a target the training loop constructed and therefore knows exactly — for the straight-line path, the target velocity is literally the difference between the two endpoints, a constant along the whole path. The connection to likelihood is a theorem about the trained model rather than a property of the objective: once the velocity field is learned, the associated probability-flow ODE transports the prior to the data distribution, and integrating the divergence along it recovers an exact log-likelihood. So the training is regression and the density comes afterwards, which is a cleaner separation than either the VAE’s bound or a flow’s architectural constraint.
- the learned velocity field — where to move at this point and this time
- the conditional target velocity, known exactly because the interpolation was constructed
- a noise sample and a data sample, paired arbitrarily; the path between them is chosen, not derived
- a point on the interpolation — the closed form is what makes training parallel across t
- the score; a fixed affine transform of this velocity, which is why the two objectives are one
How it is minimized
Adam on the velocity or score regression, with the path chosen for straightness and sampling as an ODE solve
- the noise term; present in the SDE, absent in the ODE, and the two share the same marginals
- the score, supplied by the network — the only learned quantity in either equation
- the drift of the forward corruption; fixed by the choice of process, not learned
- the only difference between the two equations: halving the score term removes the stochasticity and preserves the marginals
Two results do all the work. The first is that the stochastic process and a deterministic ODE share the same time-marginal distributions, differing only by a factor on the score term — which means you can train once and sample either way, and the deterministic route gives reproducibility, exact likelihoods, and a trajectory whose intermediate points can be skipped. That single equivalence is what turned diffusion sampling from a fixed thousand-step procedure into a solver problem where the whole numerical-integration literature applies. The second is that the path is a design choice. The variance-preserving path DDPM uses is curved, so a large step leaves the trajectory; a straight-line interpolation between noise and data has constant velocity, so in principle one step suffices and in practice a handful does. Rectification then goes further, re-pairing noise with the data it actually flowed to and retraining, which straightens the paths iteratively. This is the clearest case in this reference of improving a method by changing the problem rather than the model.
path / interpolant
Straight-line, variance-preserving, or variance-exploding. Straightness is what determines how few steps work (linear / VP / VE)
time distribution
How t is sampled during training. Uniform is standard; logit-normal emphasizes the middle where the task is hardest (uniform / logit-normal)
sampling steps
A solver choice rather than a model property. Straight paths need far fewer than curved ones (1 to 100)
solver
Euler, Heun, or a higher-order method. A better solver buys quality at the same step count for free (Euler / Heun / DPM)
rectification rounds
Re-pair and retrain to straighten the paths. Each round trades training compute for fewer sampling steps (0 to 3)
prediction target
Score, noise, or velocity. All are affine transforms of each other, but they weight the noise levels differently (v / eps / score)
Training is a regression against an exactly known target, so it inherits every good property of diffusion training: one loss, it goes down, no equilibrium, no collapse. The failures live at sampling time and are solver failures rather than optimization ones. Too few steps on a curved path produces samples that have visibly left the trajectory — and the fix is a straighter path or a better solver rather than more training, which is a genuinely different diagnosis from anything else in this reference. The score is also badly estimated at very low noise levels, where the true score diverges and the network has seen few examples that close to the data, so integrating all the way to zero noise amplifies whatever error is there; the standard fix is to stop slightly short. And the three formulations use different conventions for what the network predicts, so a checkpoint from one framework sampled by another’s code produces plausible nonsense — a conversion error rather than a model error, and a common one.
Training is O(1) network evaluations per example, exactly as in DDPM. Sampling is O(S) sequential evaluations, but S is now genuinely small: straight paths with a good solver reach acceptable quality in the tens rather than the thousands, and distillation pushes it toward one. Likelihood evaluation additionally needs the divergence of the velocity field along the trajectory, which is estimated stochastically because computing it exactly is quadratic in dimension.
How, where, and why it is used
Time-Series Forecasting
AdaptedThe same conditional setup as any diffusion forecaster — condition the velocity field on an encoded history and integrate over the forecast window — with the difference concentrated at sampling time, where a straight path and a decent solver bring the cost from hundreds of evaluations per trajectory down to tens.
- Probabilistic forecasting where the sampling cost of ordinary diffusion was the blocker
- Large panels needing many trajectories per series, where a tenfold sampling reduction decides feasibility
- Applications wanting an exact predictive likelihood, which the probability-flow ODE supplies and a bound would not
- Imputation, where the fixing-known-values trick carries over unchanged
The argument is entirely about cost. Everything that makes diffusion attractive for forecasting — unconstrained predictive distributions, jointly consistent trajectories, imputation for free — is inherited unchanged, and the sampling cost that made it impractical on a large panel drops by an order of magnitude. The exact likelihood via the deterministic sampler is a genuine secondary benefit, because it allows the model to be compared against a quantile forecaster on a common footing. Against it: still far more machinery than a quantile head, still no calibration guarantee, and on most business series the predictive distribution does not need this much freedom. The trigger is unchanged from the diffusion entry — demonstrated multimodality or a genuine need for joint samples — and only the cost objection has weakened.
- Use a straight-line interpolant, since that is where the step-count reduction comes from
- Condition the velocity field at every solver step, not only at initialization
- Stop integration slightly short of zero noise, where the score is badly estimated and error is amplified
- Report the solver and step count with every result, because they change the output as much as the model does
Continuous ranked probability score against a quantile baseline, with the step count and solver stated. The exact likelihood from the probability-flow ODE is worth reporting alongside, since it is a comparison a diffusion model could not previously offer.
- A curved path forcing many steps and losing the cost advantage entirely
- Integrating to exactly zero noise, where the score estimate is worst
- Conditioning only at initialization, so the history influence fades along the trajectory
- Comparing against a diffusion baseline at a different step count, which compares solvers
Anomaly Detection
AdaptedThe probability-flow ODE gives an exact log-likelihood, so the score is a density in the same sense as a normalizing flow’s — with the difference that the divergence term must be estimated stochastically, since computing it exactly is quadratic in dimension.
- Density-based detection where an exact likelihood is wanted and a bound will not do
- Domains where a flow’s architectural constraints are too restrictive but its exactness is needed
- Partial-noising reconstruction detection, inherited directly from the diffusion formulation
It supplies what a VAE cannot and a normalizing flow can, without the flow’s dimension-preserving architecture — which is a real structural advantage, since the architecture is free here. The caveats are inherited rather than new and they are the important part: the high-dimensional likelihood pathology applies exactly as it does to flows, so out-of-distribution inputs can score higher than training data and "low likelihood implies anomalous" is not reliable in image-scale dimensions. The stochastic divergence estimator adds variance to the very number you wanted exactly, and evaluating a likelihood means integrating an ODE, which is far more expensive than a forward pass. Validate on known out-of-distribution data before trusting it.
- Use the deterministic sampler for likelihood, since the stochastic one does not give one
- Average the divergence estimator over several draws, or the likelihood moves between evaluations of the same input
- Validate against held-out out-of-distribution data, because the high-dimensional pathology is invisible from training
- Tighten solver tolerance for likelihood relative to sampling, since density is far more sensitive to integration error
Precision and recall at a fixed alert budget, plus the explicit check that out-of-distribution data scores lower than in-distribution — the check that catches the likelihood pathology, and one that is almost never run. Report likelihood variance across divergence-estimator draws.
- The high-dimensional likelihood inversion, inherited unchanged from normalizing flows
- Reporting a likelihood as exact when the divergence term was estimated stochastically
- Integration cost making density-based scoring impractical at scale
Optimization
Primary fitThis entry is a unification result and its consequences, which makes it optimization and numerical analysis almost end to end. Three separately-developed methods — the discrete denoising chain, denoising score matching, and flow matching — turn out to be the same object under different parameterizations, and once that is seen, sampling stops being a fixed procedure and becomes a solver problem. The path itself then becomes a design variable: choose a straight one and large steps stay on the trajectory, which is where the order-of-magnitude reduction in sampling cost comes from.
- The SDE-ODE equivalence: same marginals, and the deterministic route gives reproducibility, likelihoods and skippable steps
- Higher-order solvers buying quality at fixed step count, with no retraining at all
- Path straightness as a design variable — improving the method by changing the problem rather than the model
- Rectification and distillation as iterative straightening, trading training compute for sampling steps
Worth studying because it is the clearest demonstration in this reference that reformulating a problem can be worth more than improving a solution. Nothing about the network changed between a thousand-step DDPM and a twenty-step flow-matching sampler; what changed was recognizing that sampling is integration and that the path being integrated was a free choice made badly. The SDE-ODE equivalence is the transferable technical result — two processes agreeing on their marginals while differing in every trajectory, so you can pick whichever has the properties you need. And the fact that the same trained network can be sampled stochastically for diversity or deterministically for reproducibility, with no retraining, is a design freedom almost nothing else here offers.
- Prefer a straight interpolant; curvature is precisely what forces small steps
- Use a higher-order solver before increasing the step count, since it is free quality at fixed cost
- Convert carefully between score, noise and velocity parameterizations — they are affine transforms of each other and mixing conventions silently produces nonsense
- Measure path curvature directly rather than inferring it from sample quality, since it predicts the achievable step count
Plot quality against step count for each solver and path choice; the shape of that curve is the actual result and a single number at one step count hides it. Verify the SDE and ODE samplers agree in distribution, which is both a correctness check on the implementation and a demonstration of the equivalence.
- Mixing parameterization conventions between frameworks, which produces plausible nonsense rather than an error
- Increasing step count when a better solver would have been free
- Comparing models at different step counts, which measures the sampler
- Assuming a straight path without measuring curvature, when the training may not have produced one
Computer Vision
Primary fitThe current standard formulation for image and video generation: a velocity-prediction network trained with a straight-line interpolant in a compressed latent space, sampled with a higher-order solver in a few dozen steps. The architecture is unchanged from diffusion; the path and the sampler are what moved.
- Text-to-image and text-to-video generation at current production scale
- Few-step generation via rectification or distillation, approaching single-pass latency
- Exact likelihood evaluation for model comparison, which adversarial models cannot offer
- Image editing and inpainting, inherited unchanged from the diffusion formulation
It kept everything that made diffusion win — stable training, good mode coverage, natural conditioning — and removed the objection that mattered most, which was inference cost. A few dozen steps rather than a thousand is the difference between a batch job and an interactive one, and distillation is pushing that toward single-pass, which would close the last gap to a GAN. The honest caveats are that very-few-step samples are still visibly worse than many-step ones on fine detail, and that the proliferation of parameterizations across frameworks makes checkpoint interchange genuinely error-prone in a way the DDPM era was not.
- Work in a pretrained latent space, which remains the single largest cost reduction available
- Use velocity prediction with a straight interpolant, the current default for good reasons
- Choose a higher-order solver before raising the step count
- Pin the parameterization convention explicitly when exchanging checkpoints between frameworks
Quality and diversity at a stated step count and solver, with the quality-versus-steps curve reported rather than a single point. Compare against a diffusion baseline at matched inference cost, which is the comparison that reflects the actual advance.
- Checkpoint parameterization mismatches between frameworks, producing plausible nonsense
- Very-low-step sampling degrading fine detail in ways aggregate metrics miss
- Quality numbers without a step count, which are not comparable to anything
What it costs to run
Identical in shape to DDPM — one network evaluation per example, no rollout — so the training budget is unremarkable. Rectification rounds add whole additional training runs, which is a deliberate trade of training compute for sampling steps and should be planned as such.
The reason this entry exists: tens of sequential evaluations rather than hundreds or thousands, and distillation pushes toward one. Still more than a single-pass generator, but no longer in a different regime, and the step count is a serving parameter rather than a model property.
Per project. The sampler, solver and step count can all change without retraining, which means inference quality and cost can be retuned in production against a fixed checkpoint.
- Report solver and step count with every quality measurement, since both change the output as much as the model does
- Track path curvature if rectification is used, because that is what determines how few steps remain viable
- Verify the deterministic and stochastic samplers still agree in distribution after any change, as a correctness check
- Monitor likelihood variance across divergence-estimator draws wherever density is being used
- Parameterization conventions differ between frameworks. A checkpoint predicting velocity sampled by code expecting noise produces plausible nonsense with no error anywhere, and this is the most common failure when exchanging models
- The step count is a serving parameter. Two deployments of one checkpoint at different counts produce materially different quality, and comparing them compares solvers
- Integrating to exactly zero noise amplifies the worst-estimated part of the score. Stopping slightly short is standard and its omission is a subtle quality loss
- Exact likelihood needs the deterministic sampler and a stochastic divergence estimator, so the number has variance — averaging over draws is required before it means anything
- A straight path is a property of the training, not a guarantee. Measure curvature rather than assuming the interpolant produced what it was meant to
Pros and cons, in context
An order of magnitude fewer sampling steps
Tens rather than thousands, from choosing a straight path and a better solver. The single change that made diffusion-family models viable for interactive use, and it required no architectural change at all.
One trained model, two samplers
Stochastic for diversity, deterministic for reproducibility and exact likelihoods — the same network, chosen at serving time. A design freedom almost nothing else in this reference offers.
Exact likelihood without architectural constraint
The probability-flow ODE gives a real density, where a VAE gives a bound and a normalizing flow demands an invertible dimension-preserving architecture. This gets the exactness and keeps the architecture free.
Regression training against an exact target
Inherits every good property of diffusion training — one loss, no equilibrium, no collapse — while the improvements happen entirely at sampling time.
The path is a design variable
Improving the method by changing the problem rather than the model is the transferable lesson here, and it is rare enough to be worth naming.
Parameterization conventions are a minefield
Score, noise and velocity are affine transforms of each other, and every framework picks differently. A mismatch produces plausible nonsense rather than an error, and it is the most common failure when exchanging checkpoints.
Still sequential, still more than one pass
Tens of evaluations is a large improvement on thousands and is still not a single forward pass. Where latency is the binding constraint, a GAN or a distilled model is still the answer.
The score is badly estimated near zero noise
The true score diverges there and the network has seen little data that close, so integrating all the way amplifies the error. Stopping short is standard and its omission is a subtle, easily-missed quality loss.
Exact likelihood is expensive and stochastic
It needs an ODE solve plus a divergence estimate that is quadratic in dimension if done exactly, so the practical version has variance. Calling the result exact without averaging over draws overstates it.
Straightness is not guaranteed
Choosing a straight interpolant does not ensure the learned field produces straight trajectories. Curvature has to be measured, and rectification costs whole additional training runs to fix.
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.
"""Score matching, the probability-flow ODE, and flow matching side by side.
Three formulations that look nothing alike and are the same object:
DENOISING SCORE MATCHING regress the score of the noised distribution
PROBABILITY FLOW ODE a deterministic path with the same marginals
FLOW MATCHING regress a velocity along a chosen path
The point of putting them in one file is the conversion functions at the
bottom: score, noise and velocity are AFFINE TRANSFORMS of each other. Mixing
conventions between frameworks produces plausible nonsense with no error
anywhere, which is the most common failure when exchanging checkpoints.
Plain loops, no libraries.
"""
import math
import random
SEED = 31
def network(x, t, params):
"""Predicts a direction. Which direction depends on the convention.
That ambiguity is not sloppiness - it is the actual situation. The same
architecture is trained to predict a score, a noise vector or a velocity
depending on the framework, and the caller has to know which.
"""
embedding = math.sin(t * math.pi), math.cos(t * math.pi)
hidden = []
for i, row in enumerate(params['w1']):
total = params['b1'][i] + params['w_time'][i] * embedding[i % 2]
for weight, value in zip(row, x):
total += weight * value
hidden.append(math.tanh(total))
return [
bias + sum(w * h for w, h in zip(row, hidden))
for row, bias in zip(params['w2'], params['b2'])
]
# --------------------------------------------------------------------------
# Denoising score matching
# --------------------------------------------------------------------------
def denoising_score_matching_loss(x0, sigma, params, rng):
"""Regress the score of the NOISED distribution, not of the data.
The trick that makes this tractable: the score of the data distribution is
unknown, but the score of the data-plus-known-noise distribution has a
closed form - it is just the scaled noise. So an intractable objective
becomes a regression against a target the loop constructed.
score of q(x_t | x_0) = -(x_t - x_0) / sigma^2 = -eps / sigma
"""
epsilon = [rng.gauss(0.0, 1.0) for _ in x0]
x_t = [value + sigma * e for value, e in zip(x0, epsilon)]
target = [-e / sigma for e in epsilon]
predicted = network(x_t, sigma, params)
return sum((t - p) ** 2 for t, p in zip(target, predicted)) / len(target)
# --------------------------------------------------------------------------
# Flow matching
# --------------------------------------------------------------------------
def flow_matching_loss(x0, x1, params, rng):
"""Regress a VELOCITY along a chosen path.
With a straight-line interpolation the target velocity is simply the
difference between the endpoints - a CONSTANT along the whole path. That
is the entire reason flow matching samples in few steps: a constant
velocity means one large step is exact, and in practice a handful suffice.
x_t = (1 - t) * x0 + t * x1
u_t = x1 - x0 <- constant in t
"""
t = rng.random()
x_t = [(1.0 - t) * a + t * b for a, b in zip(x0, x1)]
target = [b - a for a, b in zip(x0, x1)]
predicted = network(x_t, t, params)
return sum((u - p) ** 2 for u, p in zip(target, predicted)) / len(target)
# --------------------------------------------------------------------------
# Sampling: the same trained field, two different equations
# --------------------------------------------------------------------------
def sde_sample_step(x, t, step, params, rng, g):
"""Stochastic: inject noise at every step.
Gives diversity and does NOT give a likelihood, because the trajectory is
not a deterministic map.
"""
score = network(x, t, params)
drift = [-g * g * s for s in score]
noise_scale = math.sqrt(abs(step)) * g
return [
value + step * d + noise_scale * rng.gauss(0.0, 1.0)
for value, d in zip(x, drift)
]
def ode_sample_step(x, t, step, params, g):
"""Deterministic: the SAME marginals, with HALF the score coefficient.
That factor of one half is the only difference between the two equations,
and it is the whole result: the stochastic process and this deterministic
ODE agree on their time-marginal distributions while sharing no
trajectories at all.
What the deterministic route buys: reproducibility, an exact likelihood,
and a trajectory whose intermediate points can be SKIPPED - which is what
turned sampling from a fixed procedure into a solver problem.
"""
score = network(x, t, params)
drift = [-0.5 * g * g * s for s in score]
return [value + step * d for value, d in zip(x, drift)]
def euler_sample(dimension, params, steps, rng, g=1.0):
"""First-order integration. The simplest solver, and rarely the best.
A higher-order solver buys quality at the SAME step count for free, which
is worth trying before increasing the step count.
"""
x = [rng.gauss(0.0, 1.0) for _ in range(dimension)]
step = -1.0 / steps
# Stop slightly short of zero: the true score DIVERGES there and the
# network has seen little data that close, so integrating all the way
# amplifies the worst-estimated part of the field.
for index in range(steps):
t = 1.0 - index / steps
x = ode_sample_step(x, max(t, 1e-3), step, params, g)
return x
def heun_sample(dimension, params, steps, rng, g=1.0):
"""Second-order: evaluate, step, re-evaluate, average the two slopes.
Twice the evaluations per step and a much better step, so at a fixed
evaluation budget it usually wins. That is the cheapest quality
improvement available in this whole family.
"""
x = [rng.gauss(0.0, 1.0) for _ in range(dimension)]
step = -1.0 / steps
for index in range(steps):
t = max(1.0 - index / steps, 1e-3)
first = network(x, t, params)
predicted = [value + step * (-0.5 * g * g * s) for value, s in zip(x, first)]
next_t = max(t + step, 1e-3)
second = network(predicted, next_t, params)
x = [
value + step * (-0.5 * g * g) * 0.5 * (a + b)
for value, a, b in zip(x, first, second)
]
return x
# --------------------------------------------------------------------------
# The conversions. This is the part that causes real production failures.
# --------------------------------------------------------------------------
def noise_to_score(epsilon_prediction, sigma):
"""score = -eps / sigma."""
return [-e / sigma for e in epsilon_prediction]
def score_to_noise(score_prediction, sigma):
"""eps = -sigma * score."""
return [-sigma * s for s in score_prediction]
def velocity_to_score(velocity, x_t, t):
"""For a straight-line path, velocity and score are related through the
interpolation coefficients.
All three quantities are AFFINE TRANSFORMS of each other. A checkpoint
predicting velocity, sampled by code expecting noise, produces output that
looks like a badly trained model rather than like a bug - which is exactly
why this is worth writing down rather than leaving implicit.
"""
return [(v - value) / max(1.0 - t, 1e-6) for v, value in zip(velocity, x_t)]
def path_curvature(trajectory):
"""How far the sampled path deviates from a straight line.
This is what determines how few steps are viable, and it is worth
MEASURING rather than assuming: choosing a straight interpolant does not
guarantee the learned field produces straight trajectories.
"""
if len(trajectory) < 3:
return 0.0
start, end = trajectory[0], trajectory[-1]
straight = [b - a for a, b in zip(start, end)]
length = math.sqrt(sum(v * v for v in straight)) or 1.0
total = 0.0
for index in range(1, len(trajectory) - 1):
fraction = index / (len(trajectory) - 1)
expected = [a + fraction * d for a, d in zip(start, straight)]
deviation = math.sqrt(
sum((p - e) ** 2 for p, e in zip(trajectory[index], expected))
)
total += deviation
return total / (len(trajectory) - 2) / length
Training is O(1) network evaluations per example; sampling is O(S) for a first-order solver and O(2S) for Heun. Illustrative, not a measured benchmark: Heun doubles the per-step cost and usually wins at a fixed evaluation budget, which is why solver choice should be exhausted before step count is raised.
How this language actually wants it written.
"""The same formulations, with the parameterization made a type.
One change dominates. Score, noise and velocity are affine transforms of each
other, and every framework picks a different one — so a checkpoint predicting
velocity, sampled by code expecting noise, produces output that looks like a
badly trained model rather than like a bug. Making the convention part of the
type means that mismatch becomes an error rather than a mystery, and it is the
single highest-value piece of typing in this entry.
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from enum import Enum
from typing import NamedTuple, Protocol, Sequence
class Parameterization(Enum):
"""What the network predicts. NOT interchangeable between frameworks.
All three are affine transforms of each other, which is exactly what makes
a mismatch so dangerous: the output is plausible rather than obviously
wrong, so it reads as a poorly trained model.
"""
SCORE = 'score'
NOISE = 'noise'
VELOCITY = 'velocity'
class ParameterizationMismatch(ValueError):
"""Raised when a sampler's convention differs from a checkpoint's.
Its own type because the failure mode is specific and misleading: the
sampler runs, produces output, and that output looks like a training
problem rather than a conversion error.
"""
class DegeneratePath(ValueError):
"""Raised when an interpolant does not connect noise to data."""
class ShapeMismatch(ValueError):
"""Raised on a dimension violation instead of computing nonsense."""
class PathKind(Enum):
"""How noise and data are connected. The design variable that matters.
LINEAR gives a constant target velocity, so in principle one step suffices
and in practice a handful do. VARIANCE_PRESERVING is what DDPM uses and is
curved, which is precisely why it needs many steps.
"""
LINEAR = 'linear'
VARIANCE_PRESERVING = 'vp'
VARIANCE_EXPLODING = 've'
@dataclass(frozen=True)
class Interpolant:
"""The path between noise and data, and its exact target velocity.
Frozen because the path is a property of the trained model: a sampler
integrating a different path than the network was trained on will not stay
on any trajectory at all.
"""
kind: PathKind
def position(self, x0: Sequence[float], x1: Sequence[float], t: float) -> list[float]:
"""x_t along the path. Closed form, which is what makes training
parallel across t — no trajectory is ever simulated."""
if self.kind is PathKind.LINEAR:
return [(1.0 - t) * a + t * b for a, b in zip(x0, x1)]
# Variance-preserving: a trigonometric path keeping unit variance.
angle = t * math.pi / 2.0
return [
math.cos(angle) * a + math.sin(angle) * b for a, b in zip(x0, x1)
]
def target_velocity(
self, x0: Sequence[float], x1: Sequence[float], t: float
) -> list[float]:
"""The exact velocity along the path — the regression target.
For the linear path this is CONSTANT in t, which is the entire reason
flow matching samples in few steps: a constant velocity means one
large step is exact.
"""
if self.kind is PathKind.LINEAR:
return [b - a for a, b in zip(x0, x1)]
angle = t * math.pi / 2.0
scale = math.pi / 2.0
return [
scale * (-math.sin(angle) * a + math.cos(angle) * b)
for a, b in zip(x0, x1)
]
@property
def is_straight(self) -> bool:
return self.kind is PathKind.LINEAR
class Prediction(NamedTuple):
"""A network output AND what convention it is in.
Carrying the convention with the value is what turns a silent conversion
error into a type error. The alternative — a bare list and a comment — is
how checkpoints get sampled wrongly.
"""
values: list[float]
parameterization: Parameterization
sigma: float
t: float
def as_score(self) -> list[float]:
"""Convert to the score convention. All three are affine transforms."""
if self.parameterization is Parameterization.SCORE:
return list(self.values)
if self.parameterization is Parameterization.NOISE:
return [-value / max(self.sigma, 1e-8) for value in self.values]
# Velocity to score, for a straight path.
return [value / max(1.0 - self.t, 1e-6) for value in self.values]
def as_noise(self) -> list[float]:
if self.parameterization is Parameterization.NOISE:
return list(self.values)
return [-self.sigma * s for s in self.as_score()]
class VelocityField(Protocol):
"""A trained model, with its conventions attached.
A protocol rather than a class because the samplers below should work
against any implementation — but the conventions are part of the contract,
not an implementation detail.
"""
@property
def parameterization(self) -> Parameterization: ...
@property
def interpolant(self) -> Interpolant: ...
def __call__(self, x: Sequence[float], t: float) -> list[float]: ...
@dataclass(frozen=True)
class SamplerConfig:
"""Solver, step count, and the convention it expects.
The convention is here rather than assumed, because mismatching it is the
most common way a working checkpoint produces nonsense.
"""
steps: int
order: int = 2
expects: Parameterization = Parameterization.VELOCITY
minimum_t: float = 1e-3
def __post_init__(self) -> None:
if self.steps < 1:
raise ShapeMismatch('at least one sampling step is required')
if self.order not in (1, 2):
raise ShapeMismatch('only first- and second-order solvers are supported')
# Guard clause for the quality loss nobody notices: the true score
# DIVERGES at zero noise and the network has seen little data that
# close, so integrating all the way amplifies the worst-estimated part
# of the field.
if self.minimum_t <= 0.0:
raise ShapeMismatch(
'integration must stop short of zero; the score diverges there and '
'the estimate is at its worst'
)
def assert_compatible(self, field: VelocityField) -> None:
"""Guard clause for the failure that looks like something else."""
if self.expects is not field.parameterization:
raise ParameterizationMismatch(
f'sampler expects {self.expects.value} but the model predicts '
f'{field.parameterization.value}; these are affine transforms of each '
'other, so the output will look like a training problem'
)
def timesteps(self) -> list[float]:
"""Descending from one, stopping short of zero."""
return [
max(1.0 - index / self.steps, self.minimum_t)
for index in range(self.steps)
]
def flow_matching_loss(
x0: Sequence[float],
x1: Sequence[float],
field: VelocityField,
t: float,
) -> float:
"""Regress the velocity against a target the interpolant defines exactly.
Nothing here is a bound or an estimate: the target is constructed, so the
supervision is exact. That is what this family inherits from diffusion and
why training is so much better behaved than anything adversarial.
"""
if len(x0) != len(x1):
raise ShapeMismatch('endpoints must share a dimension')
x_t = field.interpolant.position(x0, x1, t)
target = field.interpolant.target_velocity(x0, x1, t)
predicted = field(x_t, t)
return math.fsum((u - p) ** 2 for u, p in zip(target, predicted)) / len(target)
class SampleTrace(NamedTuple):
"""The sample, the trajectory, and the settings that produced it.
The trajectory comes back because path curvature is worth measuring rather
than assuming: choosing a straight interpolant does not guarantee the
learned field produces straight trajectories, and curvature is what
determines how few steps remain viable.
"""
values: list[float]
trajectory: list[list[float]]
steps: int
order: int
def curvature(self) -> float:
"""Mean deviation from the straight line, relative to its length."""
if len(self.trajectory) < 3:
return 0.0
start, end = self.trajectory[0], self.trajectory[-1]
straight = [b - a for a, b in zip(start, end)]
length = math.sqrt(math.fsum(v * v for v in straight)) or 1.0
total = 0.0
for index in range(1, len(self.trajectory) - 1):
fraction = index / (len(self.trajectory) - 1)
expected = [a + fraction * d for a, d in zip(start, straight)]
total += math.sqrt(
math.fsum((p - e) ** 2 for p, e in zip(self.trajectory[index], expected))
)
return total / (len(self.trajectory) - 2) / length
def sample(
field: VelocityField,
config: SamplerConfig,
initial: Sequence[float],
) -> SampleTrace:
"""Integrate the velocity field. Second order by default, and for a reason.
Heun costs two evaluations per step and takes a much better step, so at a
FIXED evaluation budget it usually wins — which makes solver order the
cheapest quality improvement available, and the one to exhaust before
raising the step count.
"""
config.assert_compatible(field)
x = list(initial)
trajectory = [list(x)]
times = config.timesteps()
step = -1.0 / config.steps
for index, t in enumerate(times):
first = field(x, t)
if config.order == 1:
x = [value + step * v for value, v in zip(x, first)]
else:
predicted = [value + step * v for value, v in zip(x, first)]
next_t = times[index + 1] if index + 1 < len(times) else config.minimum_t
second = field(predicted, next_t)
x = [
value + step * 0.5 * (a + b)
for value, a, b in zip(x, first, second)
]
trajectory.append(list(x))
return SampleTrace(
values=x, trajectory=trajectory, steps=config.steps, order=config.order
)
What changed: One change dominates and it is not about arithmetic. Score, noise and velocity are affine transforms of each other, and every framework picks a different convention — so a checkpoint predicting velocity sampled by code expecting noise produces output that looks like a badly trained model rather than like a bug, which makes it the most common and most misdiagnosed failure when exchanging checkpoints. Making the convention part of the prediction type and part of the sampler config turns that silent mismatch into an error with a message that names the actual problem. The interpolant becomes a frozen type carrying both its position and its exact target velocity, which makes visible the property the whole method rests on: for a straight path the target velocity is constant in t, and that constancy is precisely why few large steps work. The sampler config refuses integration to exactly zero, because the true score diverges there and the network has seen little data that close, so the last step amplifies the worst-estimated part of the field. And the trace returns the trajectory, because path curvature determines how few steps remain viable and choosing a straight interpolant does not guarantee the learned field produced straight paths.
Identical asymptotics; the change is in what fails loudly. Illustrative, not a measured benchmark: keeping the trajectory for curvature measurement costs one copy per step, which is negligible against a network evaluation and buys the only diagnostic that predicts achievable step count.
Faster, and the justification for each change.
"""Batched, with the divergence estimated rather than computed.
Two optimizations of different kinds:
TRAINING batches exactly as DDPM does — the interpolant has a closed form,
so every example takes its own random t and there is no rollout. Nothing
clever is needed.
LIKELIHOOD is where the real algorithmic change is. The probability-flow
ODE gives an exact log-density, but it needs the DIVERGENCE of the velocity
field along the trajectory - and computing that exactly costs one
Jacobian-vector product per dimension, which is O(d) network evaluations per
step. Hutchinson's estimator gets it in ONE, by noting that the expected
value of v^T J v over random v with unit covariance IS the trace.
That substitution is what makes exact-likelihood evaluation affordable at all,
and it is why the resulting number has variance.
"""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
FLOAT = np.float32
def linear_interpolant(
x0: NDArray[np.float32], x1: NDArray[np.float32], t: NDArray[np.float32]
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
"""Positions and target velocities for a whole batch at its own times.
The target velocity for a straight path is the endpoint difference, which
is CONSTANT in t — so it is computed once per pair rather than per
timestep, and that constancy is the entire reason few steps work.
"""
coefficient = t[:, None]
velocity = x1 - x0
# Fused: the position is built from the velocity already computed rather
# than from both endpoints again.
x_t = x0 + coefficient * velocity
return x_t, velocity
def flow_matching_loss(
field,
x0: NDArray[np.float32],
x1: NDArray[np.float32],
rng: np.random.Generator,
) -> np.float32:
"""One batched step. Every example at its OWN random time.
No rollout anywhere: the interpolant has a closed form, so training is
embarrassingly parallel across examples and times alike.
"""
t = rng.random(len(x0), dtype=FLOAT)
x_t, target = linear_interpolant(x0, x1, t)
residual = field(x_t, t)
residual -= target
# einsum contracts the squared norm in one pass rather than squaring into
# a temporary and summing it.
return np.einsum('ij,ij->', residual, residual, optimize=True) / residual.size
def hutchinson_divergence(
field,
x: NDArray[np.float32],
t: NDArray[np.float32],
rng: np.random.Generator,
epsilon: float = 1e-3,
probes: int = 1,
) -> NDArray[np.float32]:
"""Trace of the Jacobian, in O(1) network evaluations rather than O(d).
The identity: for a random vector v with unit covariance,
E[ v^T J v ] = trace(J)
So one directional derivative per probe estimates the divergence,
regardless of dimension. Computing it exactly would need one
Jacobian-vector product PER DIMENSION, which at image scale is thousands
of network evaluations per integration step - entirely impractical.
The cost of the substitution is variance: the returned likelihood is an
ESTIMATE, and calling it exact without averaging over probes overstates it.
"""
base = field(x, t)
total = np.zeros(len(x), dtype=FLOAT)
for _ in range(probes):
# Rademacher probes rather than Gaussian: same unit covariance, lower
# estimator variance, and cheaper to generate.
probe = rng.integers(0, 2, x.shape).astype(FLOAT)
probe *= 2.0
probe -= 1.0
nudged = field(x + FLOAT(epsilon) * probe, t)
nudged -= base
nudged /= FLOAT(epsilon)
total += np.einsum('ij,ij->i', probe, nudged, optimize=True)
return total / probes
def ode_sample(
field,
shape: tuple[int, int],
steps: int,
rng: np.random.Generator,
order: int = 2,
minimum_t: float = 1e-3,
) -> NDArray[np.float32]:
"""Deterministic integration with Heun by default, and for a reason.
Heun costs two evaluations per step and takes a much better step, so at a
FIXED evaluation budget it usually wins - which makes solver order the
cheapest quality improvement available, and the one to exhaust before
raising the step count.
Note what is NOT here: no batching over steps. The dependency is real, and
the only lever is step count and solver order.
"""
x = rng.standard_normal(shape, dtype=FLOAT)
times = np.maximum(
1.0 - np.arange(steps, dtype=FLOAT) / steps, FLOAT(minimum_t)
)
step = FLOAT(-1.0 / steps)
for index in range(steps):
t = np.full(shape[0], times[index], dtype=FLOAT)
first = field(x, t)
if order == 1:
# In place: the state is updated through the prediction buffer.
first *= step
x += first
continue
predicted = x + step * first
next_t = np.full(
shape[0],
times[index + 1] if index + 1 < steps else FLOAT(minimum_t),
dtype=FLOAT,
)
second = field(predicted, next_t)
# Fused average-and-step: neither slope is materialized separately.
second += first
second *= step * FLOAT(0.5)
x += second
return x
def exact_log_likelihood(
field,
x: NDArray[np.float32],
steps: int,
rng: np.random.Generator,
probes: int = 4,
) -> NDArray[np.float32]:
"""Integrate the ODE BACKWARDS, accumulating the divergence.
log p(x) = log p_prior(z) + integral of divergence along the path
This is a real density rather than a bound, which is what the deterministic
sampler buys over the stochastic one. It costs an ODE solve plus a
divergence estimate per step, so it is orders of magnitude more expensive
than sampling - and the divergence is estimated, so the result has
variance that must be averaged away before it means anything.
"""
state = x.copy()
log_determinant = np.zeros(len(x), dtype=FLOAT)
step = FLOAT(1.0 / steps)
for index in range(steps):
t = np.full(len(x), FLOAT(index) / steps, dtype=FLOAT)
velocity = field(state, t)
divergence = hutchinson_divergence(field, state, t, rng, probes=probes)
state += step * velocity
log_determinant += step * divergence
# Standard Gaussian prior at the far end of the path.
prior = -0.5 * np.einsum('ij,ij->i', state, state, optimize=True)
prior -= 0.5 * x.shape[1] * np.log(2.0 * np.pi).astype(FLOAT)
return prior + log_determinant
def path_curvature(trajectory: NDArray[np.float32]) -> NDArray[np.float32]:
"""Deviation from the straight line, per sample, as one vectorized pass.
This is what determines how few steps are viable, and it is worth
MEASURING rather than assuming: choosing a straight interpolant does not
guarantee the learned field produces straight trajectories.
trajectory is (steps, batch, dim).
"""
start, end = trajectory[0], trajectory[-1]
straight = end - start
fractions = np.linspace(0.0, 1.0, len(trajectory), dtype=FLOAT)[1:-1, None, None]
expected = start + fractions * straight
deviation = trajectory[1:-1] - expected
distances = np.sqrt(np.einsum('sbd,sbd->sb', deviation, deviation, optimize=True))
lengths = np.sqrt(np.einsum('bd,bd->b', straight, straight, optimize=True))
return distances.mean(axis=0) / np.maximum(lengths, 1e-8)
What changed: Two optimizations of different kinds, and the second is the substantive one. Training batches exactly as DDPM does — the interpolant has a closed form, so every example takes its own random time and there is no rollout to parallelize — and the straight-path target velocity is computed once per pair rather than per timestep because it is constant in t, which is the same property that makes few sampling steps work. The real algorithmic change is in likelihood evaluation. The probability-flow ODE gives an exact density but needs the divergence of the velocity field along the trajectory, and computing that exactly costs one Jacobian-vector product per dimension — thousands of network evaluations per integration step at image scale, which is simply impractical. Hutchinson’s estimator gets it in one, because the expectation of a quadratic form in a unit-covariance random vector is the trace; Rademacher probes are used rather than Gaussian since they share the unit covariance with lower estimator variance. The cost of that substitution is named rather than hidden: the resulting likelihood is an estimate with variance, and calling it exact without averaging over probes overstates it.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Every squared norm and every divergence probe contracts with einsum in a single pass rather than squaring into a temporary, and the curvature measurement reduces a three-axis trajectory tensor in one contraction.
Trade-off: The curvature computation holds the whole trajectory — steps times batch times dimension — in memory at once, so measuring it on a long integration at a large batch is itself a substantial allocation.
Eliminate Python-level loops over samples
The divergence estimator, the interpolation and the loss all cover the whole batch, so the only remaining Python loops are over solver steps and probes.
Trade-off: The Hutchinson estimator trades an exact O(d) computation for a stochastic O(1) one, so the likelihood it produces has variance — the speedup is not free, it moves the cost into the number of probes needed for a stable estimate.
Pre-allocate output arrays and use in-place operations
The solver updates the state through the prediction buffers, the divergence probe is transformed in place, and the Heun average fuses into the second slope so neither is materialized separately.
Trade-off: The first slope is consumed by the fused average, so a diagnostic comparing the two Heun slopes — which is how you see that a step is too large — needs an unfused pass.
Ensure contiguous memory layout and a single dtype
float32 throughout keeps the network evaluations on the BLAS fast path and halves the bandwidth of the trajectory tensor, which is re-read by the curvature reduction.
Trade-off: float32 accumulation of the divergence integral along many steps drifts, and because the log-likelihood is that accumulation plus a prior term the error lands directly in the reported density — the one number this computation exists to produce.
Likelihood evaluation drops from O(d) network evaluations per step to O(probes). Illustrative, not a measured benchmark: at image scale that is a reduction from thousands to single digits per step, which is the difference between a computable density and an impossible one.