rtc-pi05 / rtc /masking.py
Daniel-F's picture
rtc-pi05: standalone Real-Time Chunking for openpi pi0.5
bdca518 verified
Raw History Blame Contribute Delete
5.27 kB
"""Soft prefix-attention masks for RTC guided inpainting (paper Sec. 3.2 / App. A).
Black, Galliker, Levine. "Real-Time Execution of Action Chunking Flow
Policies." NeurIPS 2025. arXiv:2506.07339.
RECONSTRUCTED MODULE. The original was lost; this restores it from the paper and
from how :mod:`rtc.sampler` calls it (``start=inference_delay``,
``end=horizon-execution_horizon``, ``total=horizon``). The three properties that
make RTC *correct* are asserted by ``test_masking.py``:
1. ``w[i] == 1`` for ``i < start`` -- the first ``d`` actions are already
committed to the robot before the new chunk lands, so they are a HARD
constraint, not a preference. Getting this wrong breaks the guarantee.
2. ``w`` is monotonically non-increasing -- confidence in the old chunk decays
with distance into the future.
3. ``w[i] == 0`` for ``i >= end`` -- past the overlap there is no previous
chunk to agree with, so the new one is unconstrained.
The exact interior curve between ``start`` and ``end`` is a smoothness
hyperparameter, not a correctness property. ``linear`` reproduces the reference
implementation's documented example exactly::
get_prefix_weights(start=2, end=6, total=10, schedule="linear")
-> [1, 1, 4/5, 3/5, 2/5, 1/5, 0, 0, 0, 0]
``exp`` is a reconstruction: same endpoints and monotonicity, but a convex decay
that holds the old chunk more tightly just after the frozen prefix and releases
it faster near ``end``. If you need bit-exact parity with the authors' ``exp``,
compare against Physical-Intelligence/real-time-chunking-kinetix ``src/model.py``
and adjust :func:`_exp_curve` only -- nothing else depends on its shape.
``zeros`` is the naive hard-masking baseline the paper compares against: freeze
the prefix, ignore the overlap entirely.
"""
from __future__ import annotations
from typing import Literal
import numpy as np
PrefixAttentionSchedule = Literal["ones", "zeros", "linear", "exp"]
_SCHEDULES = ("ones", "zeros", "linear", "exp")
def _exp_curve(u: np.ndarray) -> np.ndarray:
"""Convex remap of the linear ramp ``u`` in [0, 1], fixing u=0 -> 0, u=1 -> 1.
``u * expm1(u) / (e - 1)``: monotone increasing, so weights stay ordered.
"""
return u * np.expm1(u) / (np.e - 1.0)
def get_prefix_weights_np(
start: int,
end: int,
total: int,
schedule: PrefixAttentionSchedule = "exp",
) -> np.ndarray:
"""Soft mask ``W`` of shape ``(total,)``, float64 numpy.
Args:
start: ``d``, the inference delay. Indices ``< start`` are frozen (w = 1).
end: ``H - s``, the prefix attention horizon. Indices ``>= end`` get w = 0.
total: ``H``, the chunk length.
schedule: see the module docstring.
"""
if schedule not in _SCHEDULES:
raise ValueError(f"unknown schedule {schedule!r}; pick one of {_SCHEDULES}")
if total <= 0:
raise ValueError(f"total must be positive, got {total}")
# A delay longer than the overlap would ask for a frozen prefix the mask
# cannot express; the reference clamps rather than failing.
start = int(min(start, end))
end = int(min(max(end, 0), total))
i = np.arange(total)
if schedule == "ones":
w = np.ones(total, dtype=np.float64)
elif schedule == "zeros":
w = (i < start).astype(np.float64) # hard mask: prefix only
else:
span = end - start + 1 # matches the reference example
w = np.clip((end - i) / max(span, 1), 0.0, 1.0)
if schedule == "exp":
w = _exp_curve(w)
w = np.where(i < start, 1.0, w) # property 1: hard prefix
return np.where(i >= end, 0.0, w) # property 3
def get_prefix_weights(
start: int,
end: int,
total: int,
schedule: PrefixAttentionSchedule = "exp",
device=None,
dtype=None,
):
"""Torch flavour of :func:`get_prefix_weights_np` (what ``rtc.sampler`` uses)."""
import torch
w = get_prefix_weights_np(start, end, total, schedule)
return torch.as_tensor(w, device=device, dtype=dtype or torch.float32)
def get_prefix_weights_jax(
start: int,
end: int,
total: int,
schedule: PrefixAttentionSchedule = "exp",
dtype=None,
):
"""JAX flavour, for the JAX/orbax dual-expert backend (``rtc.sampler_jax``)."""
import jax.numpy as jnp
w = get_prefix_weights_np(start, end, total, schedule)
return jnp.asarray(w, dtype=dtype or jnp.float32)
def guidance_weight(tau: float, beta: float) -> float:
"""PiGDM guidance weight ``min(beta, (1 - tau) / (tau * r2_tau))`` (paper Eqs. 2/4).
Lives here rather than in a backend sampler so the torch and JAX paths cannot
drift apart::
inv_r2 = (tau**2 + (1 - tau)**2) / (1 - tau)**2 # 1 / r2_tau (Eq. 4)
c = (1 - tau) / tau # -> inf at tau == 0
weight = min(c * inv_r2, beta)
At ``tau == 0`` the unclipped weight is infinite (the score is undefined at pure
noise), so ``beta`` is what makes it finite -- exactly its role in App. A.2.
"""
if tau <= 0.0:
return beta
inv_r2 = (tau**2 + (1.0 - tau) ** 2) / ((1.0 - tau) ** 2)
c = (1.0 - tau) / tau
return min(c * inv_r2, beta)