# 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.
"""Base class for Vision-Language-Action (VLA) policies."""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Literal
import torch
from tensordict import TensorDict, TensorDictBase
from tensordict.nn import InteractionType, TensorDictModuleBase
from tensordict.nn.probabilistic import interaction_type
from tensordict.utils import NestedKey
from torch import distributions as torch_dist
from torchrl.data.vla.containers import VLAAction, VLAObservation
from torchrl.data.vla.schema import (
ACTION_CHUNK_KEY,
ACTION_KEY,
ACTION_TOKENS_KEY,
IMAGE_KEY,
INSTRUCTION_KEY,
STATE_KEY,
VLA_ACTION_KEY,
)
from torchrl.data.vla.tokenizers import ActionTokenizerBase
__all__ = ["VLAWrapperBase"]
ActionHead = Literal["continuous", "tokens"]
InputMode = Literal["canonical", "preprocessed"]
LogProbsMode = Literal["sequence", "token"]
OutputMode = Literal["chunk", "tokens", "both"]
SamplingMode = Literal["greedy", "sample"]
[docs]
class VLAWrapperBase(TensorDictModuleBase):
"""Base class for TensorDict-native Vision-Language-Action policies.
A VLA policy maps images, optional proprioceptive state, and a language
instruction to either a continuous action chunk or discrete action tokens.
Outputs are stored in a structured :class:`~torchrl.data.vla.VLAAction`
container under ``"vla_action"`` by default. Its fields are ordinary nested
TensorDict keys, e.g. ``("vla_action", "chunk")`` for continuous chunks.
Keyword Args:
action_dim (int): The dimensionality of a single action.
chunk_size (int): The action-chunk horizon.
action_head (str): ``"continuous"`` or ``"tokens"``.
input_mode (str): ``"canonical"`` reads raw VLA keys. ``"preprocessed"``
reads a :class:`~torchrl.data.vla.VLAObservation` or
:class:`~tensordict.TensorDictBase` from ``observation_key``.
output_mode (str, optional): ``"chunk"``, ``"tokens"`` or ``"both"``.
Defaults to ``"chunk"`` for continuous heads and ``"tokens"`` for
token heads.
return_vla_action_container (bool): whether to write the structured
:class:`~torchrl.data.vla.VLAAction` object at the VLA action root
key. When ``False``, only its plain TensorDict fields are written.
Defaults to ``True``.
vocab_size (int, optional): Number of action-token bins, required for
token heads.
action_tokenizer (ActionTokenizerBase, optional): Token/chunk codec used
when ``output_mode`` asks for both representations.
return_log_probs (bool, optional): Whether token ``forward`` writes
log-probabilities. Defaults to ``True`` for token heads.
return_logits (bool): Whether token ``forward`` writes ``action_logits``.
logits_only (bool): Whether token ``forward`` returns logits without
sampling actions by default. A per-call ``logits_only=True`` argument
also enables this path.
log_probs_mode (str): ``"sequence"`` returns one summed log-probability
per sample; ``"token"`` returns per-token log-probabilities.
use_state (bool): Whether canonical mode reads the state key.
default_interaction_type (InteractionType): Token readout when no
exploration context is active.
mode (str, optional): Backward-compatible alias mapping ``"sample"`` to
``InteractionType.RANDOM`` and ``"greedy"`` to deterministic.
inplace (bool | "empty" | None): Output TensorDict behavior. ``True``
updates the input, ``False`` returns a new output TensorDict, and
``"empty"`` returns an empty TensorDict populated with outputs.
num_samples (int, optional): Number of token samples to draw per input.
Examples:
>>> import torch
>>> from tensordict import NonTensorStack, TensorDict
>>> from torchrl.modules.vla import TinyVLA
>>> policy = TinyVLA(action_dim=7, chunk_size=4)
>>> td = TensorDict(
... {
... "observation": {
... "image": torch.zeros(2, 3, 16, 16, dtype=torch.uint8),
... "state": torch.zeros(2, 5),
... },
... "language_instruction": NonTensorStack("pick", "place"),
... },
... batch_size=[2],
... )
>>> out = policy(td)
>>> out["vla_action"].chunk.shape
torch.Size([2, 4, 7])
>>> out["vla_action", "chunk"].shape
torch.Size([2, 4, 7])
"""
@dataclass
class _AcceptedKeys:
"""Configurable tensordict keys for a VLA policy."""
image: NestedKey = IMAGE_KEY
wrist_image: NestedKey | None = ("observation", "wrist_image")
state: NestedKey | None = STATE_KEY
instruction: NestedKey = INSTRUCTION_KEY
observation: NestedKey = "vla_observation"
action: NestedKey = ACTION_KEY
vla_action: NestedKey = VLA_ACTION_KEY
action_chunk: NestedKey = ACTION_CHUNK_KEY
action_tokens: NestedKey = ACTION_TOKENS_KEY
action_logits: NestedKey = (VLA_ACTION_KEY, "logits")
action_mask: NestedKey = (VLA_ACTION_KEY, "mask")
log_probs: NestedKey = (VLA_ACTION_KEY, "log_probs")
def __init__(
self,
model=None,
*,
processor=None,
input_mode: InputMode = "canonical",
output_mode: OutputMode | None = None,
return_vla_action_container: bool = True,
action_dim: int,
chunk_size: int,
action_head: ActionHead = "continuous",
vocab_size: int | None = None,
action_tokenizer: ActionTokenizerBase | None = None,
action_kwargs: dict | None = None,
return_log_probs: bool | None = None,
return_logits: bool = False,
logits_only: bool = False,
use_state: bool = True,
default_interaction_type: InteractionType = InteractionType.DETERMINISTIC,
log_probs_mode: LogProbsMode = "sequence",
mode: SamplingMode | None = None,
image_key: NestedKey = IMAGE_KEY,
wrist_image_key: NestedKey | None = ("observation", "wrist_image"),
state_key: NestedKey | None = STATE_KEY,
instruction_key: NestedKey = INSTRUCTION_KEY,
observation_key: NestedKey = "vla_observation",
action_key: NestedKey = ACTION_KEY,
vla_action_key: NestedKey = VLA_ACTION_KEY,
action_chunk_key: NestedKey | None = None,
action_tokens_key: NestedKey | None = None,
action_logits_key: NestedKey | None = None,
action_mask_key: NestedKey | None = None,
log_probs_key: NestedKey | None = None,
inplace: Literal[True, False, "empty"] | None = True,
device: torch.device | str | None = None,
num_samples: int | None = None,
) -> None:
super().__init__()
if action_head not in ("continuous", "tokens"):
raise ValueError(
f"action_head must be 'continuous' or 'tokens', got {action_head!r}."
)
if input_mode not in ("canonical", "preprocessed"):
raise ValueError(
f"input_mode must be 'canonical' or 'preprocessed', got {input_mode!r}."
)
if output_mode is None:
output_mode = "chunk" if action_head == "continuous" else "tokens"
if output_mode not in ("chunk", "tokens", "both"):
raise ValueError(
f"output_mode must be 'chunk', 'tokens' or 'both', got {output_mode!r}."
)
if action_head == "tokens" and vocab_size is None:
raise ValueError("vocab_size must be set for the 'tokens' action head.")
if mode is not None:
if mode == "sample":
default_interaction_type = InteractionType.RANDOM
elif mode == "greedy":
default_interaction_type = InteractionType.DETERMINISTIC
else:
raise ValueError(f"mode must be 'greedy' or 'sample', got {mode!r}.")
if not isinstance(default_interaction_type, InteractionType):
raise ValueError(
"default_interaction_type must be an InteractionType, got "
f"{default_interaction_type!r}."
)
if log_probs_mode not in ("sequence", "token"):
raise ValueError(
f"log_probs_mode must be 'sequence' or 'token', got {log_probs_mode!r}."
)
if inplace not in (True, False, "empty", None):
raise ValueError(
"inplace must be True, False, 'empty' or None, got " f"{inplace!r}."
)
if num_samples is not None and int(num_samples) < 1:
raise ValueError(f"num_samples must be >= 1, got {num_samples}.")
self.model = model
self.processor = processor
self.action_dim = int(action_dim)
self.chunk_size = int(chunk_size)
self.action_head = action_head
self.input_mode = input_mode
self.output_mode = output_mode
self.return_vla_action_container = bool(return_vla_action_container)
self.vocab_size = None if vocab_size is None else int(vocab_size)
self.action_tokenizer = action_tokenizer
self.action_kwargs = {} if action_kwargs is None else dict(action_kwargs)
self.return_log_probs = (
action_head == "tokens"
if return_log_probs is None
else bool(return_log_probs)
)
self.return_logits = bool(return_logits)
self.logits_only = bool(logits_only)
self.use_state = bool(use_state)
self.default_interaction_type = default_interaction_type
self.log_probs_mode = log_probs_mode
self.inplace = True if inplace is None else inplace
self.device = None if device is None else torch.device(device)
self.num_samples = None if num_samples is None else int(num_samples)
if action_chunk_key is None:
action_chunk_key = self._vla_field_key(vla_action_key, "chunk")
if action_tokens_key is None:
action_tokens_key = self._vla_field_key(vla_action_key, "tokens")
if action_logits_key is None:
action_logits_key = self._vla_field_key(vla_action_key, "logits")
if action_mask_key is None:
action_mask_key = self._vla_field_key(vla_action_key, "mask")
if log_probs_key is None:
log_probs_key = self._vla_field_key(vla_action_key, "log_probs")
self._tensor_keys = self._AcceptedKeys(
image=image_key,
wrist_image=wrist_image_key,
state=state_key,
instruction=instruction_key,
observation=observation_key,
action=action_key,
vla_action=vla_action_key,
action_chunk=action_chunk_key,
action_tokens=action_tokens_key,
action_logits=action_logits_key,
action_mask=action_mask_key,
log_probs=log_probs_key,
)
self._update_keys()
@property
def tensor_keys(self) -> _AcceptedKeys:
return self._tensor_keys
@staticmethod
def _vla_field_key(vla_action_key: NestedKey, field: str) -> NestedKey:
if isinstance(vla_action_key, str):
return (vla_action_key, field)
return (*vla_action_key, field)
def _is_vla_field_key(self, key: NestedKey, field: str) -> bool:
return key == self._vla_field_key(self._tensor_keys.vla_action, field)
[docs]
def set_keys(self, **kwargs) -> VLAWrapperBase:
"""Set the tensordict key names used by the policy."""
old_vla_action_key = self._tensor_keys.vla_action
vla_field_keys = (
("action_chunk", "chunk"),
("action_tokens", "tokens"),
("action_logits", "logits"),
("action_mask", "mask"),
("log_probs", "log_probs"),
)
old_default_field_keys = {
key: self._vla_field_key(old_vla_action_key, field)
for key, field in vla_field_keys
}
for key, value in kwargs.items():
if key not in self._AcceptedKeys.__dataclass_fields__:
raise ValueError(
f"{key!r} is not an accepted key. Accepted keys are "
f"{list(self._AcceptedKeys.__dataclass_fields__)}."
)
setattr(self._tensor_keys, key, value)
if "vla_action" in kwargs:
for key, field in vla_field_keys:
if key not in kwargs and (
getattr(self._tensor_keys, key) == old_default_field_keys[key]
):
setattr(
self._tensor_keys,
key,
self._vla_field_key(self._tensor_keys.vla_action, field),
)
self._update_keys()
return self
def _update_keys(self) -> None:
if self.input_mode == "canonical":
in_keys = [self._tensor_keys.image]
if self.use_state and self._tensor_keys.state is not None:
in_keys.append(self._tensor_keys.state)
in_keys.append(self._tensor_keys.instruction)
else:
in_keys = [self._tensor_keys.observation]
self.in_keys = in_keys
out_keys = []
if self.action_head == "tokens" and self.logits_only:
out_keys.append(self._tensor_keys.action_logits)
self.out_keys = out_keys
return
if self.output_mode in ("chunk", "both"):
out_keys.append(self._tensor_keys.action_chunk)
if self.output_mode in ("tokens", "both"):
out_keys.append(self._tensor_keys.action_tokens)
if self.action_head == "tokens" and self.return_log_probs:
out_keys.append(self._tensor_keys.log_probs)
if self.action_head == "tokens" and self.return_logits:
out_keys.append(self._tensor_keys.action_logits)
self.out_keys = out_keys
# -- input helpers -----------------------------------------------------
def _preprocessed_observation(self, tensordict: TensorDictBase):
obs = tensordict.get(self._tensor_keys.observation)
if isinstance(obs, VLAObservation) and obs.preprocessed is not None:
return obs.preprocessed
return obs
def _get_image(self, tensordict: TensorDictBase) -> torch.Tensor:
if self.input_mode == "canonical":
return tensordict.get(self._tensor_keys.image)
obs = self._preprocessed_observation(tensordict)
if isinstance(obs, VLAObservation):
if obs.images is None or obs.images.image is None:
raise KeyError("preprocessed VLAObservation has no primary image.")
return obs.images.image
if isinstance(obs, TensorDictBase):
value = obs.get(self._tensor_keys.image, None)
if value is None:
value = obs.get("image")
return value
raise TypeError(
"preprocessed input must be a VLAObservation or TensorDictBase, got "
f"{type(obs)}."
)
def _get_state(self, tensordict: TensorDictBase) -> torch.Tensor:
if self.input_mode == "canonical":
return tensordict.get(self._tensor_keys.state)
obs = self._preprocessed_observation(tensordict)
if isinstance(obs, VLAObservation):
return obs.state
if isinstance(obs, TensorDictBase):
value = None
if self._tensor_keys.state is not None:
value = obs.get(self._tensor_keys.state, None)
if value is None:
value = obs.get("state")
return value
raise TypeError(
"preprocessed input must be a VLAObservation or TensorDictBase, got "
f"{type(obs)}."
)
def _get_instruction(self, tensordict: TensorDictBase):
if self.input_mode == "canonical":
return tensordict.get(self._tensor_keys.instruction)
obs = self._preprocessed_observation(tensordict)
if isinstance(obs, VLAObservation):
return obs.instruction
if isinstance(obs, TensorDictBase):
value = obs.get(self._tensor_keys.instruction, None)
if value is None:
value = obs.get("instruction")
return value
raise TypeError(
"preprocessed input must be a VLAObservation or TensorDictBase, got "
f"{type(obs)}."
)
# -- hooks implemented by concrete policies ---------------------------
def _predict(self, tensordict: TensorDictBase) -> torch.Tensor:
"""Return flattened continuous chunks or token logits."""
raise NotImplementedError
def _predict_chunk(self, tensordict: TensorDictBase) -> torch.Tensor:
return self._predict(tensordict).unflatten(
-1, (self.chunk_size, self.action_dim)
)
def _predict_logits(self, tensordict: TensorDictBase) -> torch.Tensor:
return self._predict(tensordict).unflatten(
-1, (self.chunk_size, self.action_dim, self.vocab_size)
)
def _action_logits(self, tensordict: TensorDictBase) -> torch.Tensor:
return self._predict_logits(tensordict)
# -- output helpers ----------------------------------------------------
def _output_tensordict(
self, tensordict: TensorDictBase, out: TensorDictBase, tensordict_out=None
) -> TensorDictBase:
if tensordict_out is None:
if self.inplace is True:
tensordict_out = tensordict
elif self.inplace is False:
tensordict_out = out
else:
tensordict_out = TensorDict(
{}, batch_size=out.batch_size, device=out.device
)
if tensordict_out is not out:
vla_action_key = self._tensor_keys.vla_action
if out.get(vla_action_key, None) is not None:
tensordict_out.set(vla_action_key, out.get(vla_action_key))
vla_action_prefix = (
(vla_action_key,)
if isinstance(vla_action_key, str)
else tuple(vla_action_key)
)
for key in out.keys(True, True):
key_tuple = (key,) if isinstance(key, str) else tuple(key)
if key_tuple[: len(vla_action_prefix)] == vla_action_prefix:
continue
tensordict_out.set(key, out.get(key))
return tensordict_out
return out
def _set_action(
self,
out: TensorDictBase,
*,
chunk: torch.Tensor | None = None,
tokens: torch.Tensor | None = None,
logits: torch.Tensor | None = None,
log_probs: torch.Tensor | None = None,
mask: torch.Tensor | None = None,
) -> None:
if self.return_vla_action_container:
action = VLAAction(
chunk=chunk,
tokens=tokens,
logits=logits,
log_probs=log_probs,
mask=mask,
batch_size=out.batch_size,
device=out.device,
)
out.set(self._tensor_keys.vla_action, action)
if chunk is not None and (
not self.return_vla_action_container
or not self._is_vla_field_key(self._tensor_keys.action_chunk, "chunk")
):
out.set(self._tensor_keys.action_chunk, chunk)
if tokens is not None and (
not self.return_vla_action_container
or not self._is_vla_field_key(self._tensor_keys.action_tokens, "tokens")
):
out.set(self._tensor_keys.action_tokens, tokens)
if logits is not None and (
not self.return_vla_action_container
or not self._is_vla_field_key(self._tensor_keys.action_logits, "logits")
):
out.set(self._tensor_keys.action_logits, logits)
if log_probs is not None and (
not self.return_vla_action_container
or not self._is_vla_field_key(self._tensor_keys.log_probs, "log_probs")
):
out.set(self._tensor_keys.log_probs, log_probs)
if mask is not None and (
not self.return_vla_action_container
or not self._is_vla_field_key(self._tensor_keys.action_mask, "mask")
):
out.set(self._tensor_keys.action_mask, mask)
def _dist_from_logits(
self, logits: torch.Tensor, mask: torch.Tensor | None = None
) -> torch_dist.Distribution:
if mask is not None:
logits = logits.masked_fill(~mask.to(torch.bool), -torch.inf)
dist = torch_dist.Categorical(logits=logits)
if self.log_probs_mode == "sequence":
return torch_dist.Independent(dist, 2)
return dist
def _sample_tokens(
self, dist: torch_dist.Distribution, logits: torch.Tensor
) -> torch.Tensor:
interaction = interaction_type()
if interaction is None:
interaction = self.default_interaction_type
batch_ndim = logits.ndim - 3
greedy = logits.argmax(-1)
if self.num_samples is None:
return dist.sample() if interaction == InteractionType.RANDOM else greedy
if interaction == InteractionType.RANDOM:
tokens = dist.sample((self.num_samples,))
return tokens.movedim(0, batch_ndim)
return greedy.unsqueeze(batch_ndim).expand(
*greedy.shape[:batch_ndim],
self.num_samples,
*greedy.shape[batch_ndim:],
)
def _log_prob_tokens(
self, dist: torch_dist.Distribution, tokens: torch.Tensor, logits: torch.Tensor
) -> torch.Tensor:
if self.num_samples is None or tokens.ndim == logits.ndim - 1:
return dist.log_prob(tokens)
batch_ndim = logits.ndim - 3
tokens_for_dist = tokens.movedim(batch_ndim, 0)
log_probs = dist.log_prob(tokens_for_dist)
return log_probs.movedim(0, batch_ndim)
# -- TensorDict API ----------------------------------------------------
[docs]
def forward(
self,
tensordict: TensorDictBase,
*,
tensordict_out: TensorDictBase | None = None,
logits_only: bool = False,
**kwargs,
) -> TensorDictBase:
if self.action_head == "continuous":
chunk = self._predict_chunk(tensordict)
tokens = None
if self.output_mode in ("tokens", "both"):
if self.action_tokenizer is None:
raise RuntimeError(
"output_mode requires action tokens but no action_tokenizer "
"was provided."
)
tokens = self.action_tokenizer.encode(chunk)
out = TensorDict({}, batch_size=chunk.shape[:-2], device=chunk.device)
self._set_action(
out,
chunk=chunk if self.output_mode in ("chunk", "both") else None,
tokens=tokens,
)
return self._output_tensordict(tensordict, out, tensordict_out)
logits = self._action_logits(tensordict)
mask = tensordict.get(self._tensor_keys.action_mask, None)
dist = self._dist_from_logits(logits, mask)
logits_only = self.logits_only or logits_only
if logits_only:
out = TensorDict({}, batch_size=logits.shape[:-3], device=logits.device)
self._set_action(out, logits=logits, mask=mask)
return self._output_tensordict(tensordict, out, tensordict_out)
tokens = self._sample_tokens(dist, logits)
log_probs = self._log_prob_tokens(dist, tokens, logits)
chunk = None
if self.output_mode in ("chunk", "both"):
if self.action_tokenizer is None:
raise RuntimeError(
"output_mode requires decoded chunks but no action_tokenizer "
"was provided."
)
chunk = self.action_tokenizer.decode(tokens)
logits_out = logits if self.return_logits else None
mask_out = mask
if logits_out is not None and self.num_samples is not None:
batch_ndim = logits.ndim - 3
logits_out = logits.unsqueeze(batch_ndim).expand(
*logits.shape[:batch_ndim],
self.num_samples,
*logits.shape[batch_ndim:],
)
if mask_out is not None and self.num_samples is not None:
batch_ndim = logits.ndim - 3
mask_out = mask.unsqueeze(batch_ndim).expand(
*mask.shape[:batch_ndim],
self.num_samples,
*mask.shape[batch_ndim:],
)
out = TensorDict({}, batch_size=tokens.shape[:-2], device=tokens.device)
self._set_action(
out,
chunk=chunk,
tokens=tokens if self.output_mode in ("tokens", "both") else None,
logits=logits_out,
log_probs=log_probs if self.return_log_probs else None,
mask=mask_out,
)
return self._output_tensordict(tensordict, out, tensordict_out)
[docs]
def get_dist(
self,
tensordict: TensorDictBase,
*,
tensordict_out: TensorDictBase | None = None,
logits_key: NestedKey | None = None,
mask_key: NestedKey | None = None,
**kwargs,
) -> torch_dist.Distribution:
"""Return the action-token distribution for loss-time recomputation."""
if self.action_head != "tokens":
raise RuntimeError(
"get_dist is only defined for the 'tokens' action head; the "
"'continuous' head is a deterministic regressor."
)
logits_key = (
self._tensor_keys.action_logits if logits_key is None else logits_key
)
mask_key = self._tensor_keys.action_mask if mask_key is None else mask_key
logits = None
if tensordict_out is not None:
logits = tensordict_out.get(logits_key, None)
if logits is None:
logits = tensordict.get(logits_key, None)
if logits is None:
logits = self._action_logits(tensordict)
mask = None
if mask_key is not None:
if tensordict_out is not None:
mask = tensordict_out.get(mask_key, None)
if mask is None:
mask = tensordict.get(mask_key, None)
return self._dist_from_logits(logits, mask)
[docs]
def log_prob(
self,
tensordict: TensorDictBase,
*,
action_key: NestedKey | None = None,
log_probs_key: NestedKey | None = None,
**kwargs,
) -> TensorDictBase:
"""Recompute and write token log-probabilities for stored actions."""
if self.action_head != "tokens":
raise RuntimeError("log_prob is only defined for token VLA policies.")
action_key = (
self._tensor_keys.action_tokens if action_key is None else action_key
)
log_probs_key = (
self._tensor_keys.log_probs if log_probs_key is None else log_probs_key
)
tokens = tensordict.get(action_key)
logits = tensordict.get(self._tensor_keys.action_logits, None)
if logits is None:
logits = self._action_logits(tensordict)
mask = tensordict.get(self._tensor_keys.action_mask, None)
dist = self._dist_from_logits(logits, mask)
log_probs = self._log_prob_tokens(dist, tokens, logits)
tensordict.set(log_probs_key, log_probs)
action = tensordict.get(self._tensor_keys.vla_action, None)
if isinstance(action, VLAAction):
action.log_probs = log_probs
return tensordict
[docs]
def get_new_version(self, **kwargs) -> VLAWrapperBase:
"""Return a shallow wrapper copy with altered runtime parameters."""
new = copy.copy(self)
new._modules = self._modules.copy()
new._parameters = self._parameters.copy()
new._buffers = self._buffers.copy()
new._tensor_keys = copy.copy(self._tensor_keys)
for key, value in kwargs.items():
if key.endswith("_key"):
field = key[: -len("_key")]
if field in self._AcceptedKeys.__dataclass_fields__:
setattr(new._tensor_keys, field, value)
continue
if not hasattr(new, key):
raise TypeError(f"{type(self).__name__} has no parameter {key!r}.")
setattr(new, key, value)
new._update_keys()
return new