Compare commits

..

1 Commits

Author SHA1 Message Date
Martino Russi ddcb61f7dd feat(flow-matching): support forward-Euler integration convention
Add a `forward_euler` flag to `euler_integrate` so the shared loop can serve
the groot/evo1/wall_x policies (integrate t: 0 -> 1, dt = +1/num_steps) in
addition to the openpi backward convention (t: 1 -> 0), which remains the
default. RTC hook and debug tracking are unchanged in both directions.

Adds forward-convention equivalence tests against a reference loop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 12:22:01 +02:00
2 changed files with 64 additions and 113 deletions
+34 -61
View File
@@ -23,7 +23,6 @@ stateless; adopting them does not affect checkpoints.
"""
from collections.abc import Callable
from functools import lru_cache
from typing import TYPE_CHECKING
import torch
@@ -33,67 +32,29 @@ if TYPE_CHECKING:
from lerobot.policies.rtc.modeling_rtc import RTCProcessor
@lru_cache(maxsize=None)
def _beta_distribution(alpha: float, beta: float) -> "torch.distributions.Beta":
# Beta sampling uses _sample_dirichlet which isn't implemented for MPS, so build on CPU.
# Cached (groot convention) so the distribution object is constructed once per (alpha, beta).
def sample_beta(alpha: float, beta: float, bsize: int, device) -> Tensor: # see openpi (exact copy)
# Beta sampling uses _sample_dirichlet which isn't implemented for MPS, so sample on CPU
alpha_t = torch.tensor(alpha, dtype=torch.float32)
beta_t = torch.tensor(beta, dtype=torch.float32)
return torch.distributions.Beta(alpha_t, beta_t)
dist = torch.distributions.Beta(alpha_t, beta_t)
return dist.sample((bsize,)).to(device)
def sample_beta(alpha: float, beta: float, bsize: int, device) -> Tensor: # see openpi (exact copy)
return _beta_distribution(alpha, beta).sample((bsize,)).to(device)
def sample_noise(shape, device) -> Tensor:
"""Standard-normal float32 noise, the flow-matching x_1 sample."""
return torch.normal(
mean=0.0,
std=1.0,
size=shape,
dtype=torch.float32,
device=device,
)
def sample_noise(shape, device, *, distribution: str = "normal") -> Tensor:
"""Float32 flow-matching noise sample.
``distribution="normal"`` (default, openpi: pi0/pi05/eo1/smolvla/groot/wall_x) draws
standard-normal noise. ``distribution="uniform"`` (evo1) draws uniformly from
``[-1, 1)`` via ``rand * 2 - 1``.
"""
if distribution == "normal":
return torch.normal(
mean=0.0,
std=1.0,
size=shape,
dtype=torch.float32,
device=device,
)
if distribution == "uniform":
return torch.rand(shape, dtype=torch.float32, device=device) * 2 - 1
raise ValueError(f"Unknown noise distribution: {distribution!r} (expected 'normal' or 'uniform')")
def sample_time_beta(
bsize: int,
device,
*,
alpha: float,
beta: float,
scale: float = 1.0,
offset: float = 0.0,
complement: bool = False,
clamp_min: float | None = None,
clamp_max: float | None = None,
) -> Tensor:
"""Beta-distributed flow-matching timesteps.
Computes ``t = f(Beta(alpha, beta)) * scale + offset`` where ``f`` is the identity by
default or ``1 - x`` when ``complement=True``, then optionally clamps to
``[clamp_min, clamp_max]``. This covers the known per-policy conventions:
* openpi backward (pi0/pi05/eo1/smolvla): ``scale=0.999, offset=0.001``.
* forward (groot/wall_x): ``complement=True, scale=0.999`` giving ``(1 - beta) * 0.999``.
* evo1: ``alpha=beta=2, clamp_min=0.02, clamp_max=0.98``.
"""
def sample_time_beta(bsize: int, device, *, alpha: float, beta: float, scale: float, offset: float) -> Tensor:
"""Beta-distributed flow-matching timesteps: ``Beta(alpha, beta) * scale + offset`` (openpi convention)."""
time_beta = sample_beta(alpha, beta, bsize, device)
if complement:
time_beta = 1.0 - time_beta
time = time_beta * scale + offset
if clamp_min is not None or clamp_max is not None:
time = time.clamp(min=clamp_min, max=clamp_max)
return time.to(dtype=torch.float32, device=device)
@@ -102,24 +63,35 @@ def euler_integrate(
noise: Tensor,
num_steps: int,
*,
forward_euler: bool = False,
rtc_processor: "RTCProcessor | None" = None,
rtc_enabled: bool = False,
inference_delay: int | None = None,
prev_chunk_left_over: Tensor | None = None,
execution_horizon: int | None = None,
) -> Tensor:
"""Forward-Euler integration of a velocity field from t=1 (noise) to t=0 (actions).
"""Euler integration of a velocity field between the noise and action endpoints.
This is the openpi sampling loop: ``dt = -1/num_steps``, ``time = 1.0 + step*dt``,
``x_t <- x_t + dt * v_t``, with the optional real-time-chunking (RTC) guidance hook
wrapping the velocity computation and debug tracking after each step.
Two integration conventions are supported via ``forward_euler``:
* Backward (default, openpi: pi0, pi05, eo1, smolvla): integrates from t=1 (noise) to
t=0 (actions) with ``dt = -1/num_steps`` and ``time = 1.0 + step*dt``.
* Forward (groot, evo1, wall_x): integrates from t=0 (noise) to t=1 (actions) with
``dt = +1/num_steps`` and ``time = step*dt``.
In both cases the update is ``x_t <- x_t + dt * v_t``, with the optional
real-time-chunking (RTC) guidance hook wrapping the velocity computation and debug
tracking after each step.
Args:
denoise_fn: Computes the velocity ``v_t`` from ``(x_t, time_tensor)`` where
``time_tensor`` is a float32 tensor of shape ``(batch_size,)``. The returned
velocity must have the same shape and dtype as ``x_t``.
noise: Initial sample ``x_1`` of shape ``(batch_size, ...)``.
noise: Initial sample of shape ``(batch_size, ...)``. This is ``x_1`` for the
backward convention and ``x_0`` for the forward convention.
num_steps: Number of Euler steps.
forward_euler: If ``True`` use the forward convention (start at t=0); otherwise
use the backward openpi convention (start at t=1).
rtc_processor: Optional RTC processor. Debug tracking fires whenever it is set and
has debugging enabled, even if RTC guidance itself is disabled (this mirrors
the historical per-policy loops).
@@ -132,10 +104,11 @@ def euler_integrate(
bsize = noise.shape[0]
device = noise.device
dt = -1.0 / num_steps
dt = 1.0 / num_steps if forward_euler else -1.0 / num_steps
t_start = 0.0 if forward_euler else 1.0
x_t = noise
for step in range(num_steps):
time = 1.0 + step * dt
time = t_start + step * dt
time_tensor = torch.tensor(time, dtype=torch.float32, device=device).expand(bsize)
def denoise_step_partial_call(input_x_t, current_timestep=time_tensor):
+30 -52
View File
@@ -24,7 +24,6 @@ reference is a behavior change for released checkpoints.
import torch
from lerobot.policies.common.flow_matching import (
_beta_distribution,
euler_integrate,
sample_beta,
sample_noise,
@@ -64,57 +63,6 @@ def test_sample_noise_seeded():
assert n1.dtype == torch.float32 and n1.shape == (2, 8, 4)
def test_sample_noise_normal_is_default():
torch.manual_seed(2)
default = sample_noise((2, 8, 4), "cpu")
torch.manual_seed(2)
normal = sample_noise((2, 8, 4), "cpu", distribution="normal")
assert torch.equal(default, normal)
def test_sample_noise_uniform_evo1():
torch.manual_seed(2)
n = sample_noise((4096,), "cpu", distribution="uniform")
assert n.dtype == torch.float32
assert n.min() >= -1.0 and n.max() < 1.0
# evo1's rand_like * 2 - 1 has mean ~0 over [-1, 1).
assert abs(n.mean().item()) < 0.05
# Exact match to the historical evo1 expression on the same RNG stream.
torch.manual_seed(2)
expected = torch.rand((4096,), dtype=torch.float32) * 2 - 1
torch.testing.assert_close(n, expected, rtol=0, atol=0)
def test_sample_noise_invalid_distribution():
import pytest
with pytest.raises(ValueError, match="Unknown noise distribution"):
sample_noise((2, 2), "cpu", distribution="bogus")
def test_sample_time_beta_forward_complement_convention():
# groot/wall_x forward convention: t = (1 - beta) * 0.999.
torch.manual_seed(9)
time = sample_time_beta(4096, "cpu", alpha=1.5, beta=1.0, scale=0.999, complement=True)
torch.manual_seed(9)
expected = (1.0 - sample_beta(1.5, 1.0, 4096, "cpu")) * 0.999
torch.testing.assert_close(time, expected, rtol=0, atol=0)
def test_sample_time_beta_evo1_clamp():
# evo1: Beta(2, 2) clamped to [0.02, 0.98].
torch.manual_seed(10)
time = sample_time_beta(4096, "cpu", alpha=2.0, beta=2.0, clamp_min=0.02, clamp_max=0.98)
assert time.min() >= 0.02 and time.max() <= 0.98
def test_sample_beta_distribution_is_cached():
a = _beta_distribution(1.5, 1.0)
b = _beta_distribution(1.5, 1.0)
assert a is b
assert _beta_distribution(2.0, 2.0) is not a
def test_euler_integrate_constant_velocity_is_exact():
# With v_t == c constant, x_0 = x_1 + sum(dt * c) = x_1 - c exactly (num_steps * dt = -1).
noise = torch.randn(3, 5, 2)
@@ -123,6 +71,36 @@ def test_euler_integrate_constant_velocity_is_exact():
torch.testing.assert_close(out, noise - c, rtol=0, atol=1e-6)
def test_euler_integrate_forward_constant_velocity_is_exact():
# Forward convention: dt = +1/num_steps, so x_1 = x_0 + sum(dt * c) = x_0 + c exactly.
noise = torch.randn(3, 5, 2)
c = torch.randn(3, 5, 2)
out = euler_integrate(lambda x_t, time: c, noise, num_steps=10, forward_euler=True)
torch.testing.assert_close(out, noise + c, rtol=0, atol=1e-6)
def _reference_forward_loop(denoise_fn, noise, num_steps):
"""Verbatim structure of the groot/evo1/wall_x forward-Euler loop (t: 0 -> 1)."""
bsize = noise.shape[0]
device = noise.device
dt = 1.0 / num_steps
x_t = noise
for step in range(num_steps):
time = 0.0 + step * dt
time_tensor = torch.tensor(time, dtype=torch.float32, device=device).expand(bsize)
x_t = x_t + dt * denoise_fn(x_t, time_tensor)
return x_t
def test_euler_integrate_forward_matches_reference_loop():
torch.manual_seed(7)
denoise_fn = _make_denoise_fn()
noise = torch.randn(2, 6, 4)
ref = _reference_forward_loop(denoise_fn, noise, 10)
out = euler_integrate(denoise_fn, noise, 10, forward_euler=True)
assert torch.equal(out, ref)
def _reference_pi0_loop(denoise_fn, noise, num_steps, rtc_enabled, rtc_processor, kw):
"""Verbatim structure of the historical pi0/pi05/smolvla sample_actions loop."""
bsize = noise.shape[0]