Flow Matching¶
Flow matching is the primary training objective in FlowPDE. This page covers the theory and how it maps onto the code.
Background¶
Given a source distribution \(p_0\) (Gaussian noise) and a target distribution \(p_1\) (PDE solutions), flow matching learns a time-dependent velocity field \(v_\theta\) that transports samples from \(p_0\) to \(p_1\):
where \(f\) is the conditioning variable — the PDE source term, coefficient field, or initial condition.
Training Objective¶
The loss regresses the model's velocity against the ground-truth conditional velocity:
where \(x_t\) interpolates between noise \(x_0\) and data \(x_1\) along a chosen path.
The loss has an irreducible floor
Many \((x_0, x_1)\) pairs give the same \(x_t\), so the model regresses a conditional
average and the loss never approaches zero. Do not use train_loss for model
selection — select on FlowEvaluator output.
Path Interpolants¶
A path is a pair of functions, interpolate() and velocity().
Linear Path (default)¶
OT-Conditional Path¶
Adds a noise bridge with variance vanishing at both endpoints:
Invariant
A path's velocity() must be the exact time derivative of its interpolate(). If
they disagree, training silently regresses a target that does not match the
interpolation the model is shown. tests/test_components.py checks this by finite
differences — keep that test passing when adding a path.
Time Sampling¶
How \(t\) is sampled during training affects convergence:
| Sampler | Distribution | Use Case |
|---|---|---|
uniform |
\(t \sim U(0, 1)\) | General purpose |
logit_normal |
\(t = \sigma(z),\ z \sim \mathcal{N}(\mu, \sigma^2)\) | Weights the middle of the path |
beta |
\(t \sim \mathrm{Beta}(\alpha, \beta)\) | Flexible concentration |
Every sampler is constructible directly, which is how you tune its parameters or keep \(t\) away from exactly 0 and 1, where the velocity target can be numerically awkward:
from flowpde.flows.components.time_samplers import UniformSampler
objective = FlowMatchingObjective(
flow,
time_sampler=UniformSampler(low=1e-5, high=1 - 1e-5),
)
This is the "string or instance" rule the component getters follow throughout: anywhere
a name is accepted, a constructed object is too. The getters also forward keyword
arguments to the constructor, so get_time_sampler('logit_normal', std=0.5) and
LogitNormalSampler(std=0.5) are the same thing.
Couplings¶
How noise and data are paired within a batch:
- Independent — random pairing (standard)
- Mini-batch OT — optimal transport within the mini-batch via
scipy.optimize.linear_sum_assignment, reducing crossing trajectories
Sources¶
The source distribution is a first-class component because reflow depends on it:
GaussianSource— draws fresh \(x_0 \sim \mathcal{N}(0, I)\) (default)BatchSource— consumes the \(x_0\) already present in the batch, which is what makes reflow correct
Rectified Flow and Reflow¶
These are two different things, and the distinction matters.
The objective. create_flow_matching(flow, variant='rectified') sets a linear path
with logit-normal time sampling. Note that 1-rectified flow is mathematically identical
to standard flow matching with a linear path and independent coupling — the objective
alone straightens nothing. (The logit-normal sampler is from Esser et al. 2024/SD3;
Liu et al. use uniform.)
Reflow is the procedure that does the straightening: generate \((z, \mathrm{ODE}(z))\) pairs with the current model, retrain on those pairs, repeat.
from flowpde.flows import BatchSource
from flowpde.trainers import generate_reflow_pairs, reflow
pairs = generate_reflow_pairs(objective, loader, n_steps=100) # (z, ODE(z))
objective.source = BatchSource() # consume that exact z
# ...or run the whole loop:
reflow(objective, loader,
optimizer_factory=lambda p: torch.optim.Adam(p, lr=1e-4),
num_iterations=2, epochs_per_iteration=50)
Reflow pairs must be preserved
Reflow training must use the same \(z\) that produced each generated target. The pairing is deterministic and induced by the model; resampling noise independently decouples the pairs and silently reduces reflow to training on the model's own samples.
BatchSource is strict=True by default so a missing key raises rather than
silently resampling, and compute_loss skips the coupling when the source already
defines the pairing — mini-batch OT would otherwise reorder the pairs and destroy
them.
Measuring Straightness¶
estimate_straightness implements the Liu et al. definition — deviation from the
chord, not the spread of velocity norms. A field that turns at constant speed must
not score as straight.
It takes a batch, not a DataLoader:
batch = next(iter(loader))
s = objective.estimate_straightness(batch, mode="trajectory") # the model's own ODE paths
s = objective.estimate_straightness(batch, mode="interpolant") # the training interpolant
s["straightness"] # 0 = perfectly straight
s["normalized_straightness"] # dimensionless, comparable across datasets
Sampling¶
At inference, integrate the learned ODE from noise to solution:
Adaptive solvers (dopri5, dopri8) adjust step size automatically and ignore
n_steps; fixed-step solvers (euler, rk4) use it directly, which is what you vary
when reporting accuracy against solver-step count.
A Note on Uncertainty¶
For the Poisson and Burgers forward maps, \(p(u \mid f)\) is a Dirac — the solution is deterministic. Sampler spread there is model error, not physical uncertainty. Put the UQ story on inverse problems, where the posterior is genuinely non-degenerate.