Rate this Page

Source code for torchrl.data.replay_buffers.query

# 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.

from __future__ import annotations

import operator
import warnings
from collections.abc import Callable, Iterator, Sequence

import numpy as np
import torch
from tensordict import NestedKey, TensorClass, TensorDictBase
from tensordict.utils import unravel_key

from torchrl._utils import DEFAULT_DONE_KEYS
from torchrl.data.replay_buffers.utils import find_start_stop_traj

_DEFAULT_TRAJECTORY_KEYS = (("collector", "traj_ids"), "traj_ids", "episode")
# TED convention: a trajectory ends when any of done / terminated / truncated
# is set, so all present end signals within a group are OR-ed together.
_END_KEY_GROUPS = (
    tuple(("next", key) for key in DEFAULT_DONE_KEYS),
    tuple(DEFAULT_DONE_KEYS),
)


[docs] class Trajectory(TensorClass["nocast"]): """A trajectory-first view over a contiguous slice of transitions. A single-field tensorclass wrapping a :class:`~tensordict.TensorDictBase` that holds the transitions of one trajectory, exposing its entries as attributes. Attribute lookup resolves keys against the root tensordict first and falls back to the ``("next", ...)`` sub-tensordict, so ``traj.reward`` and ``traj.done`` return the conventional post-step entries while ``traj.observation``, ``traj.state`` or ``traj.action`` return root entries. Being a tensorclass, slicing and indexing return :class:`Trajectory` instances, and trajectories of different lengths can be assembled into a single ragged batch with :func:`~tensordict.lazy_stack`. Attributes: data (TensorDictBase): a tensordict with a single batch dimension of length ``T`` holding the trajectory's transitions in order. Examples: >>> trajectory = Trajectory(data) >>> trajectory.observation.shape torch.Size([200, 3]) >>> trajectory.reward.sum() tensor(-1234.5678) >>> trajectory.length 200 >>> trajectory[:10] Trajectory(length=10, keys=['action', 'next', 'observation']) >>> from tensordict import lazy_stack >>> stacked = lazy_stack([trajectory, other_trajectory]) """ data: TensorDictBase def __post_init__(self) -> None: if not self.batch_size: data = self.data if data is not None and data.batch_dims: if data.batch_dims != 1: raise ValueError( "Trajectory expects data with a single batch dimension, " f"got batch_size={data.batch_size}." ) self.batch_size = data.batch_size @property def length(self) -> int: """Number of transitions in the trajectory.""" return int(self.batch_size[0]) @property def total_reward(self) -> torch.Tensor: """Sum of rewards over the trajectory.""" return self.reward.sum()
[docs] def get(self, key: NestedKey, default=None) -> torch.Tensor | None: """Resolve ``key`` against the root tensordict, then ``("next", key)``.""" data = self.data value = data.get(key, None) if value is not None: return value if isinstance(key, str): value = data.get(("next", key), None) if value is not None: return value return default
[docs] def keys(self, *args, **kwargs): """The keys of the underlying tensordict.""" return self.data.keys(*args, **kwargs)
_TENSORCLASS_GETATTR = Trajectory.__getattr__ _TENSORCLASS_GETITEM = Trajectory.__getitem__ def _trajectory_getattr(self, name: str): """Falls back to data-key resolution for names the tensorclass does not own. The tensorclass machinery installs its own ``__getattr__`` after class creation, so the fallback is chained here rather than defined in the class body. """ try: return _TENSORCLASS_GETATTR(self, name) except AttributeError: if name.startswith("_"): raise value = self.get(name) if value is None: raise AttributeError( f"Trajectory has no entry {name!r} in its data (looked up {name!r} and {('next', name)!r}). " f"Available keys: {sorted(map(str, self.data.keys(include_nested=True, leaves_only=True)))}" ) from None return value def _trajectory_getitem(self, index): """Routes key indexing to the data and batch indexing to the tensorclass.""" if isinstance(index, str) or (isinstance(index, tuple) and unravel_key(index)): return self.data[index] return _TENSORCLASS_GETITEM(self, index) def _trajectory_repr(self) -> str: if len(self.batch_size) == 1: shape = f"length={self.length}" else: shape = f"batch_size={tuple(self.batch_size)}" data = self.data keys = sorted(map(str, data.keys())) if data is not None else None return f"Trajectory({shape}, keys={keys})" Trajectory.__getattr__ = _trajectory_getattr Trajectory.__getitem__ = _trajectory_getitem Trajectory.__repr__ = _trajectory_repr def _union_required_keys( first: frozenset | None, second: frozenset | None ) -> frozenset | None: if first is None or second is None: return None return first | second
[docs] class TrajectoryPredicate: """A boolean predicate over a :class:`Trajectory`. Predicates are produced by comparing trajectory expressions (see :data:`traj`) and compose with ``&`` (and), ``|`` (or) and ``~`` (not). Examples: >>> predicate = (traj.reward.sum() > 100) & (traj.length < 200) >>> predicate(trajectory) True >>> predicate.required_keys() frozenset({'reward'}) """ def __init__( self, fn: Callable[[Trajectory], bool], description: str = "predicate", keys: frozenset | None = None, ) -> None: self._fn = fn self._description = description self._keys = keys
[docs] def required_keys(self) -> frozenset | None: """The data keys the predicate reads, or ``None`` when unknown. Predicates built from :data:`traj` expressions report the exact set of keys they need, which lets :meth:`ReplayBuffer.query <torchrl.data.ReplayBuffer.query>` fetch only those entries from the storage. Predicates wrapping opaque callables return ``None``. """ return self._keys
def __call__(self, trajectory: Trajectory) -> bool: return bool(self._fn(trajectory)) def __and__(self, other: TrajectoryPredicate) -> TrajectoryPredicate: return TrajectoryPredicate( lambda t: self(t) and other(t), f"({self._description} & {other._description})", keys=_union_required_keys(self._keys, other._keys), ) def __or__(self, other: TrajectoryPredicate) -> TrajectoryPredicate: return TrajectoryPredicate( lambda t: self(t) or other(t), f"({self._description} | {other._description})", keys=_union_required_keys(self._keys, other._keys), ) def __invert__(self) -> TrajectoryPredicate: return TrajectoryPredicate( lambda t: not self(t), f"~{self._description}", keys=self._keys ) def __repr__(self) -> str: return f"TrajectoryPredicate({self._description})"
class _ElementwisePredicate: """An elementwise comparison awaiting reduction via ``.any()`` or ``.all()``.""" def __init__( self, fn: Callable[[Trajectory], torch.Tensor], description: str, keys: frozenset | None = None, ) -> None: self._fn = fn self._description = description self._keys = keys def any(self) -> TrajectoryPredicate: """True if the comparison holds for any transition.""" return TrajectoryPredicate( lambda t: bool(self._fn(t).any()), f"{self._description}.any()", keys=self._keys, ) def all(self) -> TrajectoryPredicate: """True if the comparison holds for every transition.""" return TrajectoryPredicate( lambda t: bool(self._fn(t).all()), f"{self._description}.all()", keys=self._keys, ) def __call__(self, trajectory: Trajectory | None = None): raise TypeError( f"{self._description} compares per-transition values and is ambiguous as a " "trajectory filter. Call .any() or .all() to reduce it to a single boolean." ) def __repr__(self) -> str: return f"_ElementwisePredicate({self._description})" class _ComparableExpr: """Mixin providing the comparison dunders shared by trajectory expressions. Subclasses set ``_comparison_cls`` to the wrapper produced by a comparison and implement ``_value`` to evaluate the expression on a trajectory. """ _comparison_cls: type def _value(self, trajectory: Trajectory): raise NotImplementedError def _compare(self, op, other, symbol: str): return self._comparison_cls( lambda t: op(self._value(t), other), f"({self._description} {symbol} {other!r})", keys=self._keys, ) def __gt__(self, other): return self._compare(operator.gt, other, ">") def __ge__(self, other): return self._compare(operator.ge, other, ">=") def __lt__(self, other): return self._compare(operator.lt, other, "<") def __le__(self, other): return self._compare(operator.le, other, "<=") def __eq__(self, other): # type: ignore[override] return self._compare(operator.eq, other, "==") def __ne__(self, other): # type: ignore[override] return self._compare(operator.ne, other, "!=") __hash__ = None class _ScalarExpr(_ComparableExpr): """A scalar-valued expression over a trajectory, comparable into a predicate.""" _comparison_cls = TrajectoryPredicate def __init__( self, fn: Callable[[Trajectory], torch.Tensor | float | int], description: str, keys: frozenset | None = None, ) -> None: self._fn = fn self._description = description self._keys = keys def _value(self, trajectory: Trajectory): return self._fn(trajectory) def __repr__(self) -> str: return f"_ScalarExpr({self._description})" class _FieldExpr(_ComparableExpr): """A per-transition field of a trajectory, reducible or comparable.""" _comparison_cls = _ElementwisePredicate def __init__(self, key: NestedKey, description: str | None = None) -> None: self._key = key self._description = description if description is not None else f"traj.{key}" self._keys = frozenset({unravel_key(key)}) def _get(self, trajectory: Trajectory) -> torch.Tensor: value = trajectory.get(self._key) if value is None: raise KeyError( f"Key {self._key!r} not found in trajectory (looked up root and ('next', ...))." ) return value def _value(self, trajectory: Trajectory): return self._get(trajectory) def _reduce(self, fn, name: str) -> _ScalarExpr: return _ScalarExpr( lambda t: fn(self._get(t)), f"{self._description}.{name}()", keys=self._keys, ) def sum(self) -> _ScalarExpr: """Sum of the field over the trajectory.""" return self._reduce(torch.sum, "sum") def mean(self) -> _ScalarExpr: """Mean of the field over the trajectory.""" return self._reduce(lambda x: x.float().mean(), "mean") def max(self) -> _ScalarExpr: """Maximum of the field over the trajectory.""" return self._reduce(torch.max, "max") def min(self) -> _ScalarExpr: """Minimum of the field over the trajectory.""" return self._reduce(torch.min, "min") def first(self) -> _ScalarExpr: """The field's value at the first transition.""" return _ScalarExpr( lambda t: self._get(t)[0], f"{self._description}.first()", keys=self._keys ) def last(self) -> _ScalarExpr: """The field's value at the last transition.""" return _ScalarExpr( lambda t: self._get(t)[-1], f"{self._description}.last()", keys=self._keys ) def __repr__(self) -> str: return f"_FieldExpr({self._description})" class _TrajectoryRef: """Entry point of the trajectory query language. Attribute access returns a field expression resolved against each trajectory (root keys first, then ``("next", ...)``); indexing with an explicit (possibly nested) key does the same for keys that are not valid attribute names. Field expressions support elementwise comparisons (reduce with ``.any()``/``.all()``) and reductions (``.sum()``, ``.mean()``, ``.max()``, ``.min()``, ``.first()``, ``.last()``) that compare into :class:`TrajectoryPredicate` objects composable with ``&``, ``|`` and ``~``. Examples: >>> from torchrl.data import traj >>> predicate = (traj.reward.sum() > 100) & (traj.length >= 50) >>> high_reward_trajs = replay_buffer.query(predicate) >>> spiky = (traj.reward > 10).any() | (traj.step_count.last() > 500) >>> masked = traj[("collector", "mask")].all() """ @property def length(self) -> _ScalarExpr: """The number of transitions in the trajectory.""" return _ScalarExpr(lambda t: t.length, "traj.length", keys=frozenset()) @property def total_reward(self) -> _ScalarExpr: """The sum of rewards over the trajectory.""" return _ScalarExpr( lambda t: t.total_reward, "traj.total_reward", keys=frozenset({"reward"}) ) def __getattr__(self, name: str) -> _FieldExpr: if name.startswith("_"): raise AttributeError(name) return _FieldExpr(name) def __getitem__(self, key: NestedKey) -> _FieldExpr: return _FieldExpr(key, description=f"traj[{key!r}]") def __repr__(self) -> str: return "traj" traj = _TrajectoryRef() def _last_write_index(cursor) -> int | None: """Best-effort extraction of the last written flat index from a storage cursor.""" if cursor is None: return None if isinstance(cursor, torch.Tensor): if cursor.numel() == 0: return None return int(cursor.reshape(-1)[-1]) if isinstance(cursor, np.ndarray): if cursor.size == 0: return None return int(cursor.reshape(-1)[-1]) if isinstance(cursor, range): return cursor[-1] if len(cursor) else None if isinstance(cursor, int): return cursor return None def _boundary_signal( source: TensorDictBase, trajectory_key: NestedKey | None ) -> tuple[str, torch.Tensor]: """Fetches the entry that individuates trajectories from ``source``. Returns a ``(kind, signal)`` pair where ``kind`` is ``"trajectory"`` for per-element trajectory ids and ``"end"`` for the OR-ed end-of-episode flags, with the signal reshaped to the batch shape of ``source``. Warns when falling back to end flags, since two back-to-back trajectories without a positive end flag in between cannot be told apart. """ batch_size = source.batch_size keys = (trajectory_key,) if trajectory_key is not None else _DEFAULT_TRAJECTORY_KEYS for key in keys: ids = source.get(key, None) if ids is not None: return "trajectory", ids.reshape(batch_size) if trajectory_key is not None: raise KeyError(f"trajectory_key {trajectory_key!r} not found in data.") for key_group in _END_KEY_GROUPS: end = None found = [] for end_key in key_group: flag = source.get(end_key, None) if flag is not None: flag = flag.reshape(batch_size) end = flag if end is None else end | flag found.append(end_key) if end is not None: warnings.warn( "No trajectory id entry was found; splitting on the end-of-episode " f"flags {tuple(found)}. Any trajectory whose last transition does not " "carry a positive end flag will be silently merged with the " "following one. Store trajectory ids (e.g. ('collector', " "'traj_ids')) or pass trajectory_key explicitly for reliable " "splitting.", category=UserWarning, ) return "end", end raise KeyError( "Cannot split data into trajectories: no trajectory id entry found " f"(looked up {_DEFAULT_TRAJECTORY_KEYS}) and no end-of-episode entry " f"found (looked up {_END_KEY_GROUPS}). Pass trajectory_key explicitly." ) def _trajectory_boundaries( source: TensorDictBase, trajectory_key: NestedKey | None, *, at_capacity: bool = False, cursor=None, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Computes ``(start_idx, stop_idx, lengths)`` for the trajectories in ``source``. Boundary recovery is delegated to :func:`~torchrl.data.find_start_stop_traj`, the same machinery :class:`~torchrl.data.replay_buffers.samplers.SliceSampler` uses, so samplers and queries always agree on trajectory boundaries. """ kind, signal = _boundary_signal(source, trajectory_key) return find_start_stop_traj( trajectory=signal if kind == "trajectory" else None, end=signal if kind == "end" else None, at_capacity=at_capacity, cursor=cursor, ) def _chronological_order( start_idx: torch.Tensor, *, storage_length: int, batch_shape: torch.Size, at_capacity: bool, cursor, ) -> torch.Tensor: """Orders trajectory boundaries chronologically within each batch column. Once a round-robin storage has wrapped, storage order no longer matches write order: the oldest remaining element sits right after the write cursor. Trajectories are keyed by their time offset from that position (grouped by batch coordinates for multi-dimensional storages). """ offset = 0 if at_capacity: last = _last_write_index(cursor) if last is not None: offset = (last % storage_length + 1) % storage_length logical = (start_idx[:, 0].to(torch.long) - offset) % storage_length key = logical if start_idx.shape[1] > 1: flat_batch = torch.zeros_like(logical) for dim in range(1, start_idx.shape[1]): flat_batch = flat_batch * batch_shape[dim] + start_idx[:, dim].to( torch.long ) key = flat_batch * storage_length + logical return torch.argsort(key) def _extract_trajectory_data( source: TensorDictBase, start: torch.Tensor, stop: torch.Tensor, length: int, storage_length: int, ) -> TensorDictBase: """Reads one trajectory from a time-first view, following the wrap point. Contiguous trajectories are returned as zero-copy slices; only a trajectory written across the storage wrap point requires a gather. """ time_start = int(start[0]) time_stop = int(stop[0]) batch_coords = tuple(int(coord) for coord in start[1:]) if time_start <= time_stop: return source[(slice(time_start, time_stop + 1), *batch_coords)] index = torch.arange(time_start, time_start + length) % storage_length return source[(index, *batch_coords)] def _as_key_tuple(key: NestedKey) -> tuple: key = unravel_key(key) return key if isinstance(key, tuple) else (key,) def _keys_overlap(first: NestedKey, second: NestedKey) -> bool: first = _as_key_tuple(first) second = _as_key_tuple(second) depth = min(len(first), len(second)) return first[:depth] == second[:depth] def _expand_required_keys(keys: frozenset) -> set: """Adds the ``("next", key)`` candidates used by attribute resolution.""" expanded = set() for key in keys: key = unravel_key(key) expanded.add(key) if isinstance(key, str): expanded.add(("next", key)) return expanded def _minimal_transform_plan( transforms: Sequence, required: set ) -> tuple[list, set | None]: """Selects the transform suffix and storage keys a predicate needs. Walks the transform chain backward, keeping a transform only if its ``out_keys`` can affect a required key and accumulating its ``in_keys`` into the required set. A transform without ``out_keys`` metadata has unknown effects, so it and every transform before it are kept and all keys are fetched (returned as ``None``). """ kept_reversed = [] required = set(required) for index in range(len(transforms) - 1, -1, -1): transform = transforms[index] out_keys = getattr(transform, "out_keys", None) or [] if not out_keys: kept = list(transforms[: index + 1]) + list(reversed(kept_reversed)) return kept, None if any( _keys_overlap(out_key, req_key) for out_key in out_keys for req_key in required ): kept_reversed.append(transform) in_keys = getattr(transform, "in_keys", None) or [] required.update(unravel_key(key) for key in in_keys) return list(reversed(kept_reversed)), required def _apply_transforms(data: TensorDictBase, transforms: Sequence) -> TensorDictBase: if not transforms: return data with data.unlock_(): for transform in transforms: data = transform(data) return data def _query_source( source: TensorDictBase, *, transforms: Sequence = (), predicate: Callable[[Trajectory], bool] | None = None, trajectory_key: NestedKey | None = None, at_capacity: bool = False, cursor=None, ) -> list[Trajectory]: """Splits a time-first storage view into trajectories and filters them. This is the engine behind :meth:`ReplayBuffer.query <torchrl.data.ReplayBuffer.query>`. Predicates built from :data:`traj` expose :meth:`TrajectoryPredicate.required_keys`, which is used to fetch only the entries the predicate reads (and run only the transforms that can affect them) during evaluation. Matching trajectories are then extracted in full, with the complete transform chain applied per trajectory, mirroring what a sampler would return. """ if isinstance(predicate, _ElementwisePredicate): raise TypeError( f"{predicate._description} compares per-transition values and is " "ambiguous as a trajectory filter. Call .any() or .all() to " "reduce it to a single boolean." ) if not source.batch_dims: raise ValueError( "Trajectory queries expect a storage with at least one batch " f"dimension, got batch_size={source.batch_size}." ) storage_length = source.batch_size[0] if storage_length == 0: return [] start_idx, stop_idx, lengths = _trajectory_boundaries( source, trajectory_key, at_capacity=at_capacity, cursor=cursor ) if not start_idx.numel(): return [] order = _chronological_order( start_idx, storage_length=storage_length, batch_shape=source.batch_size, at_capacity=at_capacity, cursor=cursor, ) transforms = list(transforms) eval_source = None eval_transforms = transforms eval_is_full = True if predicate is not None: required = None required_keys_fn = getattr(predicate, "required_keys", None) if callable(required_keys_fn): required = required_keys_fn() if required is None: eval_source = source else: expanded = _expand_required_keys(required) eval_transforms, fetch_keys = _minimal_transform_plan(transforms, expanded) if fetch_keys is None: eval_source = source else: eval_source = source.select(*fetch_keys, strict=False) eval_is_full = False eval_is_full = eval_is_full and len(eval_transforms) == len(transforms) if eval_transforms and eval_source is source: eval_source = source.copy() eval_source = _apply_transforms(eval_source, eval_transforms) results = [] for position in order.tolist(): start = start_idx[position] stop = stop_idx[position] length = int(lengths[position]) if predicate is None: matched = True eval_trajectory = None else: eval_data = _extract_trajectory_data( eval_source, start, stop, length, storage_length ) eval_trajectory = Trajectory(eval_data) matched = bool(predicate(eval_trajectory)) if not matched: continue if eval_trajectory is not None and eval_is_full: results.append(eval_trajectory) continue full_data = _extract_trajectory_data( source, start, stop, length, storage_length ) full_data = _apply_transforms(full_data, transforms) results.append(Trajectory(full_data)) return results
[docs] def iter_trajectories( data: TensorDictBase, trajectory_key: NestedKey | None = None ) -> Iterator[Trajectory]: """Iterate over the trajectories stored in a flat batch of transitions. Consecutive transitions are grouped into trajectories using, in order of preference: an explicit ``trajectory_key``, the conventional ``("collector", "traj_ids")`` / ``"traj_ids"`` / ``"episode"`` entries, or the union of the ``("next", "done")`` / ``("next", "terminated")`` / ``("next", "truncated")`` end flags. Transitions belonging to the same trajectory are assumed to be stored contiguously and in order, as written by the standard round-robin writers. Boundary recovery shares the machinery of :class:`~torchrl.data.replay_buffers.samplers.SliceSampler`. .. warning:: When no trajectory id entry is available, splitting falls back to the end-of-episode flags and a ``UserWarning`` is emitted: a trajectory whose last transition does not carry a positive end flag cannot be distinguished from the following one and the two are silently merged. Store trajectory ids for reliable splitting. Args: data (TensorDictBase): a tensordict of transitions with a single batch dimension. trajectory_key (NestedKey, optional): entry holding per-transition trajectory ids. Defaults to None (auto-detection). Yields: :class:`Trajectory` views over ``data``. """ if not isinstance(data, TensorDictBase): raise TypeError( f"iter_trajectories expects a TensorDictBase, got {type(data)}. " "Trajectory queries require tensordict-backed storages." ) if data.batch_dims != 1: raise ValueError( "iter_trajectories expects data with a single batch dimension " f"(flat transitions), got batch_size={data.batch_size}." ) if data.batch_size[0] == 0: return start_idx, stop_idx, _ = _trajectory_boundaries(data, trajectory_key) for start, stop in zip(start_idx[:, 0].tolist(), stop_idx[:, 0].tolist()): yield Trajectory(data[start : stop + 1])
[docs] def filter_trajectories( data: TensorDictBase, predicate: Callable[[Trajectory], bool] | None = None, *, trajectory_key: NestedKey | None = None, ) -> list[Trajectory]: """Split ``data`` into trajectories and keep those matching ``predicate``. Args: data (TensorDictBase): a tensordict of transitions with a single batch dimension. predicate (Callable[[Trajectory], bool], optional): a :class:`TrajectoryPredicate` built from :data:`traj`, or any callable mapping a :class:`Trajectory` to a boolean. Defaults to None (keep all trajectories). Keyword Args: trajectory_key (NestedKey, optional): entry holding per-transition trajectory ids. Defaults to None (auto-detection). Returns: A list of matching :class:`Trajectory` views. Examples: >>> from torchrl.data import filter_trajectories, traj >>> good = filter_trajectories(data, traj.reward.sum() > 100) """ if isinstance(predicate, _ElementwisePredicate): raise TypeError( f"{predicate._description} compares per-transition values and is " "ambiguous as a trajectory filter. Call .any() or .all() to " "reduce it to a single boolean." ) return [ trajectory for trajectory in iter_trajectories(data, trajectory_key) if predicate is None or predicate(trajectory) ]