Filed under Diffusion & Generative Modeling

Before reading this Diffusion models or Autoencoders?

Contents

Flow Matching, a short overview

In summary, flow matching is a generative modeling technique that provides an elegant way to transform data distributions.

To make that concrete before the code, here is a flow running in the page: a Gaussian carried onto two moons. Nothing is trained; for a finite set of pairs the marginal velocity field has a closed form, so what you see is the exact flow, and the only thing that changes between the three options is how the noise and data points are paired and which path connects them.

Flow matching with exact marginal velocities, from N(0, I) to 256 target points. Independent: the straight path xₜ = (1−t)x₀ + t x₁ with x₀ and x₁ independent; conditioning on x₁ = bⱼ gives N(t bⱼ, (1−t)²I), so uₜ(x) is the posterior-weighted average of (bⱼ − x)/(1−t), in closed form. VP diffusion: the same construction on the path sin(πt/2)x₁ + cos(πt/2)x₀. Minibatch OT: noise and data are paired by an exact assignment (Hungarian algorithm) inside each minibatch, and batch 256 is exact OT on the whole sample. That coupling has no closed form for a continuous source, so each of its 256 pairs is widened into a Gaussian tube of width 0.15(1−t) (a kernel estimate of the coupling) and the marginal velocity of that mixture is computed exactly. Orange lines are the ODE solved with RK4 (120 steps); dashed lines and rings are Euler with n steps. Straightness is chord over arc length (1 = straight); Euler error is the mean endpoint distance to the RK4 solution; →data is the mean distance from an endpoint to the nearest target point.

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

class FlowMatchingModel:
    def __init__(self, input_dim, hidden_dim):
        """
        Initialize Flow Matching Model
        
        Args:
            input_dim (int): Dimension of input data
            hidden_dim (int): Dimension of hidden layers
        """
        self.base_distribution = None  # Initial data distribution
        self.target_distribution = None  # Target data distribution
        
        # Neural network to learn the flow
        self.flow_network = nn.Sequential(
            nn.Linear(input_dim + 1, hidden_dim),  # +1 for time conditioning
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, input_dim)
        )
        
        self.optimizer = optim.Adam(self.flow_network.parameters())
    
    def sample_base_distribution(self, num_samples):
        """
        Sample from the base (initial) distribution
        
        Args:
            num_samples (int): Number of samples to generate
        
        Returns:
            torch.Tensor: Samples from base distribution
        """
        # Example: Gaussian distribution
        return torch.randn(num_samples, self.input_dim)
    
    def probability_flow_ode(self, x, t):
        """
        Compute the probability flow ODE
        
        Args:
            x (torch.Tensor): Current data point
            t (torch.Tensor): Time variable
        
        Returns:
            torch.Tensor: Flow direction
        """
        # Combine input and time as network input
        network_input = torch.cat([x, t], dim=1)
        return self.flow_network(network_input)
    
    def conditional_vector_field(self, x0, x1, t):
        """
        Compute the conditional vector field
        
        Args:
            x0 (torch.Tensor): Initial data point
            x1 (torch.Tensor): Target data point
            t (torch.Tensor): Time variable
        
        Returns:
            torch.Tensor: Conditional vector field
        """
        # Interpolate between source and target
        x_t = x0 * (1 - t) + x1 * t
        vector_field = x1 - x0
        return vector_field
    
    def loss_function(self, x0, x1):
        """
        Compute the flow matching loss
        
        Args:
            x0 (torch.Tensor): Initial data points
            x1 (torch.Tensor): Target data points
        
        Returns:
            torch.Tensor: Training loss
        """
        batch_size = x0.shape[0]
        t = torch.rand(batch_size, 1)  # Random time sampling
        
        # Compute vector field
        true_vector_field = self.conditional_vector_field(x0, x1, t)
        predicted_vector_field = self.probability_flow_ode(x0, t)
        
        # Compute MSE loss between true and predicted vector fields
        loss = torch.mean((predicted_vector_field - true_vector_field) ** 2)
        return loss
    
    def train(self, dataloader, epochs):
        """
        Train the Flow Matching Model
        
        Args:
            dataloader (torch.utils.data.DataLoader): Training data
            epochs (int): Number of training epochs
        """
        for epoch in range(epochs):
            for batch_x0, batch_x1 in dataloader:
                self.optimizer.zero_grad()
                loss = self.loss_function(batch_x0, batch_x1)
                loss.backward()
                self.optimizer.step()
            
            print(f"Epoch {epoch+1}/{epochs}, Loss: {loss.item()}")
    
    def generate_samples(self, num_samples):
        """
        Generate new samples using the learned flow
        
        Args:
            num_samples (int): Number of samples to generate
        
        Returns:
            torch.Tensor: Generated samples
        """
        # Start from base distribution and follow the learned flow
        x0 = self.sample_base_distribution(num_samples)
        
        # Perform sampling through ODE solving
        x_generated = x0  # Starting point
        time_steps = torch.linspace(0, 1, 100)
        
        for t in time_steps[1:]:
            vector_field = self.probability_flow_ode(x_generated, t)
            x_generated += vector_field * (time_steps[1] - time_steps[0])
        
        return x_generated

def main():
    # Hyperparameters
    input_dim = 10
    hidden_dim = 64
    num_epochs = 100
    
    flow_matching = FlowMatchingModel(input_dim, hidden_dim)
    flow_matching.train(dataloader, num_epochs)
    generated_samples = flow_matching.generate_samples(1000)

The math behind the code #

The code above is short, but each line rests on a derivation. In this section I want to go through them in order. I follow the convention of Lipman et al. and of the code: time runs from $t = 0$ to $t = 1$, $x_0 \sim p_0$ is a sample from a simple source (a standard Gaussian), and $x_1 \sim q$ is a data sample. All vectors live in $\mathbb{R}^d$.

Continuous normalizing flows #

A time-dependent vector field $v_t : \mathbb{R}^d \to \mathbb{R}^d$ defines a flow $\phi_t$ through the ODE

$$\frac{d}{dt}\phi_t(x) = v_t\big(\phi_t(x)\big), \qquad \phi_0(x) = x.$$

Pushing the source through the flow gives a path of densities $p_t = [\phi_t]_{\#}\, p_0$, and we want $p_1 \approx q$. If $v_t$ is a neural network $v_\theta(x, t)$, this is a continuous normalizing flow (CNF), which is what probability_flow_ode and generate_samples implement. The second one is an Euler solver.

The density along the path obeys the continuity equation, which says that probability mass is conserved as it is transported by $v_t$:

$$\partial_t p_t(x) + \nabla \cdot \big(p_t(x)\, v_t(x)\big) = 0.$$

From it we get the instantaneous change of variables of Chen et al. Follow a single trajectory $x(t) = \phi_t(x_0)$ and differentiate $\log p_t$ along it with the chain rule:

$$\frac{d}{dt}\log p_t\big(x(t)\big) = \frac{\partial_t p_t + \nabla p_t \cdot v_t}{p_t} = \frac{-\nabla\cdot(p_t v_t) + \nabla p_t \cdot v_t}{p_t} = -\nabla \cdot v_t\big(x(t)\big),$$

because $\nabla\cdot(p_t v_t) = \nabla p_t \cdot v_t + p_t\, \nabla\cdot v_t$. Integrating from $0$ to $1$:

$$\log p_1\big(x(1)\big) = \log p_0\big(x(0)\big) - \int_0^1 \operatorname{tr}\Big(\frac{\partial v_t}{\partial x}\big(x(t)\big)\Big)\, dt.$$

That is the CNF analogue of the $\log|\det J|$ term in a discrete normalizing flow. A determinant becomes a trace. In principle you can train a CNF by maximum likelihood with this formula, but every gradient step needs an ODE solve, plus a trace estimate (Hutchinson’s estimator, in FFJORD). That is slow, and it is the motivation for flow matching, which never simulates the ODE during training.

The flow-matching objective #

Suppose we had a target probability path $p_t$ with $p_0$ the source and $p_1 \approx q$, and a vector field $u_t$ that generates it (meaning the pair satisfies the continuity equation). Then we could simply regress:

$$\mathcal{L}_{\text{FM}}(\theta) = \mathbb{E}_{t \sim \mathcal{U}[0,1],\ x \sim p_t}\,\big\|v_\theta(x, t) - u_t(x)\big\|^2.$$

The problem is that we know neither $p_t$ nor $u_t$ in closed form. Lipman et al. solve it by building the path out of simple per-sample pieces. Let $z$ be some conditioning variable with distribution $q(z)$, and for each $z$ pick a conditional path $p_t(x \mid z)$ generated by a conditional field $u_t(x \mid z)$. Define the marginals

$$\begin{aligned} p_t(x) &= \int p_t(x \mid z)\, q(z)\, dz, \\ u_t(x) &= \int u_t(x \mid z)\, \frac{p_t(x \mid z)\, q(z)}{p_t(x)}\, dz = \mathbb{E}\big[u_t(x \mid z) \mid x_t = x\big]. \end{aligned}$$

The marginal field is the posterior average of the conditional fields. It really does generate the marginal path, because the continuity equation is linear:

$$\begin{aligned} \partial_t p_t(x) &= \int \partial_t p_t(x \mid z)\, q(z)\, dz = -\int \nabla\cdot\big(u_t(x \mid z)\, p_t(x \mid z)\big)\, q(z)\, dz \\ &= -\nabla\cdot\big(u_t(x)\, p_t(x)\big). \end{aligned}$$

Conditional flow matching with the linear path #

In the code, the conditioning variable is the pair $z = (x_0, x_1)$ with $x_0 \sim p_0$ and $x_1 \sim q$ drawn independently. The conditional path is the straight line between them, and its velocity is constant:

$$x_t = (1-t)\, x_0 + t\, x_1, \qquad u_t(x_t \mid x_0, x_1) = \frac{d x_t}{dt} = x_1 - x_0.$$

This is exactly conditional_vector_field. It is the $\sigma_{\min} \to 0$ limit of the “optimal transport” path of Lipman et al., who condition on $x_1$ alone and use $x_t = (1 - (1-\sigma_{\min})t)\, x_0 + t\, x_1$, with target $x_1 - (1-\sigma_{\min})\, x_0$. It is also the interpolation used by rectified flow. The conditional flow matching loss regresses on these per-pair targets:

$$\mathcal{L}_{\text{CFM}}(\theta) = \mathbb{E}_{t \sim \mathcal{U}[0,1],\ x_0 \sim p_0,\ x_1 \sim q}\, \big\|v_\theta\big((1-t)\,x_0 + t\,x_1,\ t\big) - (x_1 - x_0)\big\|^2.$$

One detail that matters when you implement it: the network must be evaluated at the interpolated point $x_t$, not at the endpoint $x_0$.

Why the CFM gradient equals the FM gradient #

At first sight the CFM loss is the wrong objective. A given point $x_t$ lies on many lines with different directions $x_1 - x_0$, so the regression target is noisy. The surprise is that it does not matter. Expand both squares (at a fixed $t$, then average over $t$):

$$\|v_\theta - u\|^2 = \|v_\theta\|^2 - 2\langle v_\theta, u\rangle + \|u\|^2.$$

  • The $\|v_\theta\|^2$ term. In both losses $x$ is distributed as the marginal $p_t$, so $\mathbb{E}_{p_t(x)}\|v_\theta(x,t)\|^2 = \mathbb{E}_{q(z),\, p_t(x \mid z)}\|v_\theta(x,t)\|^2$.
  • The cross term. Substitute the definition of $u_t$, which cancels the $p_t(x)$ in its denominator: $\mathbb{E}_{p_t(x)}\langle v_\theta(x,t), u_t(x)\rangle = \int \big\langle v_\theta(x,t), \int u_t(x \mid z)\, p_t(x \mid z)\, q(z)\, dz \big\rangle\, dx = \mathbb{E}_{q(z),\, p_t(x \mid z)}\langle v_\theta(x,t), u_t(x \mid z)\rangle.$
  • The $\|u\|^2$ terms differ between the two losses, but neither depends on $\theta$.

Therefore $\mathcal{L}_{\text{FM}}(\theta) = \mathcal{L}_{\text{CFM}}(\theta) + C$ for a constant $C$, so $\nabla_\theta \mathcal{L}_{\text{FM}} = \nabla_\theta \mathcal{L}_{\text{CFM}}$. Equivalently, the minimizer of a squared loss is a conditional expectation, and here $\arg\min \mathcal{L}_{\text{CFM}}$ is $v^*(x, t) = \mathbb{E}[x_1 - x_0 \mid x_t = x] = u_t(x)$. The noise in the targets only adds variance to the stochastic gradient, not bias. Training is fully simulation-free: sample $t$, $x_0$, $x_1$, interpolate, regress.

Rectified flow and straightness #

The learned field is an average of straight-line velocities, so the ODE it defines is generally not straight. Where lines from different pairs cross, the average turns, and ODE trajectories cannot cross at all (solutions are unique). They bend and “rewire” which noise sample ends up at which data point. Curved trajectories are exactly what makes few-step Euler integration inaccurate.

Liu et al. measure this with the straightness of a flow $Z = \{Z_t\}$:

$$S(Z) = \int_0^1 \mathbb{E}\Big[\big\|(Z_1 - Z_0) - \dot Z_t\big\|^2\Big]\, dt.$$

$S(Z) = 0$ means every trajectory moves at constant velocity along a straight line. Then a single Euler step $Z_1 = Z_0 + v(Z_0, 0)$ is exact. Rectified flow improves straightness with reflow:

  1. Train $v^{(1)}$ with the CFM loss on independent pairs $(x_0, x_1)$.
  2. Draw $Z_0 \sim p_0$ and solve the learned ODE to get $Z_1$. The pair $(Z_0, Z_1)$ is a new coupling of $p_0$ and $p_1$ that is deterministic.
  3. Train $v^{(2)}$ with the same loss, but on the pairs $(Z_0, Z_1)$ instead of independent ones. Repeat if desired.

Each rectification preserves the marginals. Liu et al. also show that it never increases the transport cost $\mathbb{E}[c(Z_1 - Z_0)]$ for any convex $c$, and that iterating it drives the straightness towards zero. Since the new pairs are already matched by a flow with non-crossing paths, their connecting lines cross far less, the averaged field is closer to the per-pair one, and the ODE can be solved in one or a few steps. That is the basis of one-step rectified-flow samplers.

References #

#flow-matching#generative

Comments