# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""Action tokenizers for autoregressive (token) VLA policies."""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
import torch
from torch import nn
if TYPE_CHECKING:
from torchrl.data.vla.metadata import RobotDatasetMetadata
__all__ = [
"ActionTokenizerBase",
"UniformActionTokenizer",
"VocabTailActionTokenizer",
]
[docs]
class ActionTokenizerBase(nn.Module):
"""Base class for action tokenizers.
An action tokenizer maps continuous actions to discrete token ids and back,
so that autoregressive (RT-2 / OpenVLA-style) VLA policies can emit actions
through a language-model head and be trained with token cross-entropy.
A tokenizer operates element-wise over the trailing action dimension, so it
works unchanged on per-step actions ``[*B, action_dim]`` and on action
chunks ``[*B, T, chunk, action_dim]``.
Subclasses implement :meth:`encode`, :meth:`decode` and the
:attr:`vocab_size` property.
"""
[docs]
def encode(self, actions: torch.Tensor) -> torch.Tensor:
"""Map continuous actions ``[..., action_dim]`` to token ids (``long``)."""
raise NotImplementedError
[docs]
def decode(self, tokens: torch.Tensor) -> torch.Tensor:
"""Map token ids back to continuous actions ``[..., action_dim]``."""
raise NotImplementedError
@property
def vocab_size(self) -> int:
"""Number of distinct token ids the tokenizer can emit per position."""
raise NotImplementedError
[docs]
class VocabTailActionTokenizer(ActionTokenizerBase):
r"""OpenVLA-style vocab-tail action tokenizer.
OpenVLA (`arXiv:2406.09246 <https://arxiv.org/abs/2406.09246>`_)
discretizes each normalized action dimension over the *edges* of
``num_bins`` uniform bins spanning ``[-1, 1]`` and writes the result into
the last ``num_bins`` ids of the language-model vocabulary:
``full_token_id = vocab_size - digitize(action)``. Decoding maps a token
back to the corresponding bin center (there are ``num_bins - 1`` centers).
This tokenizer reproduces that exact mapping, with two id conventions:
- **window ids** (default, ``full_vocab_size=None``): ids in
``[0, num_bins)`` -- the offset of the token inside the vocab-tail
window, ``window_id = num_bins - digitize(action)``. This is the
convention of a token-head VLA policy emitting a ``num_bins``-way
categorical per action dimension (e.g.
:class:`~torchrl.modules.vla.VLAWrapperBase` with
``vocab_size=num_bins``).
- **full ids**: pass ``full_vocab_size`` (e.g. ``32000`` for LLaMA-2) to
use raw language-model token ids,
``full_id = full_vocab_size - digitize(action)``.
Optionally, dataset statistics (the ``norm_stats`` shipped with OpenVLA
checkpoints) un-normalize decoded actions to the environment's action
space -- and normalize actions before encoding -- via the affine q01/q99
map ``a_env = 0.5 * (a + 1) * (q99 - q01 + 1e-8) + q01`` applied to the
dimensions selected by ``mask`` (the gripper dimension is typically
excluded). To reproduce the reference MuJoCo action path exactly, decoding
with normalization statistics is performed in NumPy float64 on CPU; the
result is cast back to ``float32`` on the input tokens' device. See
:meth:`from_norm_stats` and :meth:`decode`.
Args:
num_bins (int): number of bin edges per action dimension (the OpenVLA
convention; there are ``num_bins - 1`` bin centers). Defaults to
``256``.
Keyword Args:
full_vocab_size (int, optional): if provided, tokens are raw
language-model ids in ``[full_vocab_size - num_bins,
full_vocab_size)`` instead of window offsets. Defaults to
``None``.
norm_low (torch.Tensor, optional): per-dimension lower statistics
(``q01``) for un-normalization. Defaults to ``None`` (no
normalization; actions live in ``[-1, 1]``).
norm_high (torch.Tensor, optional): per-dimension upper statistics
(``q99``).
norm_mask (torch.Tensor, optional): boolean mask of the dimensions to
(un-)normalize; unmasked dimensions pass through. Defaults to all
``True`` when statistics are given.
gripper_binarize (bool, optional): if ``True``, binarize unmasked
dimensions (usually gripper) to ``-1`` / ``+1`` after decoding.
Defaults to ``False``.
gripper_binarize_threshold (float, optional): threshold used for
gripper binarization: values strictly above this threshold map to
``+1``, the rest to ``-1``. Defaults to ``0.0``.
gripper_invert (bool, optional): if ``True``, flip the sign of
unmasked dimensions after optional binarization. Defaults to
``False``.
Examples:
>>> import torch
>>> from torchrl.data.vla import VocabTailActionTokenizer
>>> tok = VocabTailActionTokenizer(256)
>>> tokens = tok.encode(torch.tensor([-1.0, 0.0, 1.0]))
>>> tokens
tensor([255, 128, 0])
>>> tok.decode(tokens)
tensor([-0.9961, 0.0000, 0.9961])
>>> # full LM-vocabulary ids (LLaMA-2)
>>> tok = VocabTailActionTokenizer(256, full_vocab_size=32000)
>>> tok.encode(torch.tensor([-1.0, 0.0, 1.0]))
tensor([31999, 31872, 31744])
>>> tok.vocab_size
32000
.. seealso:: :class:`~torchrl.data.vla.UniformActionTokenizer` for the
plain bin-index codec used by toy token policies.
"""
def __init__(
self,
num_bins: int = 256,
*,
full_vocab_size: int | None = None,
norm_low: torch.Tensor | None = None,
norm_high: torch.Tensor | None = None,
norm_mask: torch.Tensor | None = None,
gripper_binarize: bool = False,
gripper_binarize_threshold: float = 0.0,
gripper_invert: bool = False,
) -> None:
super().__init__()
self.gripper_binarize = bool(gripper_binarize)
self.gripper_binarize_threshold = float(gripper_binarize_threshold)
self.gripper_invert = bool(gripper_invert)
if num_bins < 2:
raise ValueError(f"num_bins must be >= 2, got {num_bins}.")
if full_vocab_size is not None and full_vocab_size < num_bins:
raise ValueError(
f"full_vocab_size ({full_vocab_size}) must be at least "
f"num_bins ({num_bins})."
)
if (norm_low is None) != (norm_high is None):
raise ValueError("norm_low and norm_high must be provided together.")
self.num_bins = int(num_bins)
self.full_vocab_size = (
int(full_vocab_size) if full_vocab_size is not None else None
)
bins = torch.linspace(-1.0, 1.0, num_bins)
self.register_buffer("bins", bins)
self.register_buffer("bin_centers", (bins[:-1] + bins[1:]) / 2.0)
if norm_low is not None:
# The reference action path performs this affine in NumPy float64.
# Preserve the checkpoint's JSON precision for exact CPU decode;
# device-side encode/decode casts these buffers to the action dtype.
norm_low = torch.as_tensor(norm_low, dtype=torch.float64)
norm_high = torch.as_tensor(norm_high, dtype=torch.float64)
if norm_mask is None:
norm_mask = torch.ones_like(norm_low, dtype=torch.bool)
else:
norm_mask = torch.as_tensor(norm_mask, dtype=torch.bool)
self.register_buffer("norm_low", norm_low)
self.register_buffer("norm_high", norm_high)
self.register_buffer("norm_mask", norm_mask)
else:
self.norm_low = self.norm_high = self.norm_mask = None
@property
def vocab_size(self) -> int:
if self.full_vocab_size is not None:
return self.full_vocab_size
return self.num_bins
def _digitize(self, actions: torch.Tensor) -> torch.Tensor:
# exact torch port of np.digitize(clip(a, -1, 1), bins): index of the
# first bin edge strictly greater than the value, i.e. in [1, num_bins]
actions = actions.clamp(-1.0, 1.0)
# run on the input's device: the tokenizer lives in a (CPU) env
# transform but its inputs can come from a policy on another device
return torch.bucketize(actions, self.bins.to(actions.device), right=True)
[docs]
def encode(self, actions: torch.Tensor) -> torch.Tensor:
if self.norm_low is not None:
norm_low = self.norm_low.to(device=actions.device, dtype=actions.dtype)
norm_high = self.norm_high.to(device=actions.device, dtype=actions.dtype)
norm_mask = self.norm_mask.to(actions.device)
# same affine convention as decode / the OpenVLA reference:
# a = 2 * (a_env - q01) / (q99 - q01 + 1e-8) - 1
scale = norm_high - norm_low + 1e-8
normalized = 2.0 * (actions - norm_low) / scale - 1.0
actions = torch.where(norm_mask, normalized, actions)
digitized = self._digitize(actions)
if self.full_vocab_size is not None:
return self.full_vocab_size - digitized
return self.num_bins - digitized
[docs]
def decode(self, tokens: torch.Tensor) -> torch.Tensor:
"""Map token ids back to continuous actions ``[..., action_dim]``.
When normalization statistics are set (see :meth:`from_norm_stats`),
the de-tokenization and the q01/q99 un-normalization are computed in
NumPy float64 on CPU for bit-exact parity with the OpenVLA-OFT
reference implementation. This incurs a device-to-host round-trip
(and a host sync) on every call when ``tokens`` lives on an
accelerator; the result is moved back to ``tokens.device`` and cast
to the tokenizer's working dtype (``float32``).
Without statistics, decoding runs entirely on ``tokens.device`` and
returns bin centers in ``[-1, 1]``.
"""
if self.norm_low is not None:
# LIBERO executes CPU actions. Reproduce SimpleVLA's NumPy float64
# de-tokenization before handing those values to MuJoCo; float32
# stats or a missing range epsilon make fragile closed-loop
# rollouts diverge after several chunks.
tokens_np = tokens.detach().cpu().numpy()
if self.full_vocab_size is not None:
digitized = self.full_vocab_size - tokens_np
else:
digitized = self.num_bins - tokens_np
bins = np.linspace(-1.0, 1.0, self.num_bins)
bin_centers = (bins[:-1] + bins[1:]) / 2.0
index = np.clip(digitized - 1, a_min=0, a_max=bin_centers.shape[0] - 1)
actions = bin_centers[index]
norm_low = self.norm_low.detach().cpu().numpy()
norm_high = self.norm_high.detach().cpu().numpy()
norm_mask = self.norm_mask.detach().cpu().numpy().astype(bool)
unnormalized = (
0.5 * (actions + 1.0) * (norm_high - norm_low + 1e-8) + norm_low
)
actions = np.where(norm_mask, unnormalized, actions)
if self.gripper_binarize or self.gripper_invert:
gripper = ~norm_mask
if self.gripper_binarize:
binary = (actions > self.gripper_binarize_threshold).astype(
actions.dtype
) * 2.0 - 1.0
actions = np.where(gripper, binary, actions)
if self.gripper_invert:
actions = np.where(gripper, -actions, actions)
# ride the input device and the tensor-path output dtype so the
# float64 CPU detour does not leak into downstream action chunks
return torch.from_numpy(np.ascontiguousarray(actions)).to(
device=tokens.device, dtype=self.bin_centers.dtype
)
# operate on the tokens' device (policy and env may differ): move the
# small lookup buffer there so the output rides the input device
bin_centers = self.bin_centers.to(tokens.device)
if self.full_vocab_size is not None:
digitized = self.full_vocab_size - tokens
else:
digitized = self.num_bins - tokens
index = (digitized - 1).clamp(0, bin_centers.shape[0] - 1)
return bin_centers[index]
[docs]
@classmethod
def from_norm_stats(
cls,
norm_stats: dict,
unnorm_key: str,
*,
num_bins: int = 256,
full_vocab_size: int | None = None,
gripper_binarize: bool = False,
gripper_binarize_threshold: float = 0.0,
gripper_invert: bool = False,
) -> VocabTailActionTokenizer:
"""Build from the ``norm_stats`` dictionary of an OpenVLA checkpoint.
Args:
norm_stats (dict): the checkpoint's normalization statistics
(``model.norm_stats``), mapping dataset keys to
``{"action": {"q01": ..., "q99": ..., "mask": ...}}``.
unnorm_key (str): the dataset key to use (e.g.
``"libero_spatial_no_noops"``).
num_bins (int, optional): number of bin edges. Defaults to ``256``.
full_vocab_size (int, optional): raw language-model vocabulary size
when using full token ids. Defaults to ``None``.
gripper_binarize (bool, optional): whether to binarize unmasked
gripper dimensions after decoding. Defaults to ``False``.
gripper_binarize_threshold (float, optional): threshold used for
gripper binarization. Defaults to ``0.0``.
gripper_invert (bool, optional): whether to invert unmasked gripper
dimensions after optional binarization. Defaults to ``False``.
"""
if unnorm_key not in norm_stats:
raise KeyError(
f"unnorm_key {unnorm_key!r} not found in norm_stats; available "
f"keys: {sorted(norm_stats)}."
)
stats = norm_stats[unnorm_key]["action"]
mask = stats.get("mask")
return cls(
num_bins,
full_vocab_size=full_vocab_size,
norm_low=torch.as_tensor(stats["q01"], dtype=torch.float64),
norm_high=torch.as_tensor(stats["q99"], dtype=torch.float64),
norm_mask=torch.as_tensor(mask, dtype=torch.bool)
if mask is not None
else None,
gripper_binarize=gripper_binarize,
gripper_binarize_threshold=gripper_binarize_threshold,
gripper_invert=gripper_invert,
)