Rate this Page

Source code for torchrl.data.replay_buffers.samplers.random

# 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

from collections import OrderedDict
from pathlib import Path
from typing import Any

import numpy as np
import torch
from tensordict import TensorDict
from torchrl.data.replay_buffers.storages import Storage
from torchrl.data.replay_buffers.utils import _is_int, unravel_index

_EMPTY_STORAGE_ERROR = "Cannot sample from an empty storage."


from .base import Sampler


class RandomSampler(Sampler):
    """A uniformly random sampler for composable replay buffers.

    Keyword Args:
        replacement (bool, optional): if ``False``, the call is dispatched to
            :class:`SamplerWithoutReplacement`, and any additional keyword
            arguments (e.g. ``drop_last``, ``shuffle``) are forwarded to its
            constructor. Defaults to ``True``.

    Examples:
        >>> from torchrl.data import RandomSampler, SamplerWithoutReplacement
        >>> isinstance(RandomSampler(), RandomSampler)
        True
        >>> isinstance(RandomSampler(replacement=False), SamplerWithoutReplacement)
        True
        >>> isinstance(
        ...     RandomSampler(replacement=False, drop_last=True),
        ...     SamplerWithoutReplacement,
        ... )
        True

    """

    def sample(self, storage: Storage, batch_size: int) -> tuple[torch.Tensor, dict]:
        if len(storage) == 0:
            raise RuntimeError(_EMPTY_STORAGE_ERROR)
        index = storage._rand_given_ndim(batch_size)
        return index, {}

    def _empty(self):
        pass

    def dumps(self, path):
        # no op
        ...

    def loads(self, path):
        # no op
        ...

    def state_dict(self) -> dict[str, Any]:
        return {}

    def load_state_dict(self, state_dict: dict[str, Any]) -> None:
        return


class ConsumingSampler(Sampler):
    """A random sampler that consumes entries after they have been sampled.

    ``ConsumingSampler`` tracks how many times each storage index has been
    returned by :meth:`sample`. Once an index has been returned
    ``max_sample_count`` times, it is removed from the set of sampleable
    indices until that slot is overwritten by the replay-buffer writer. When
    used through :class:`~torchrl.data.ReplayBuffer`, consumed indices are kept
    in a free-list so writes can reuse those slots before advancing the normal
    writer cursor.

    Args:
        max_sample_count (int, optional): number of returned samples after which
            an item is consumed. Defaults to ``1``.

    Examples:
        >>> import torch
        >>>
        >>> from torchrl.data import ConsumingSampler, ListStorage, ReplayBuffer
        >>> rb = ReplayBuffer(
        ...     storage=ListStorage(10),
        ...     sampler=ConsumingSampler(),
        ...     batch_size=3,
        ... )
        >>> rb.extend([torch.tensor(i) for i in range(4)])
        tensor([0, 1, 2, 3])
        >>> sample = rb.sample()
        >>> len(sample)
        3
        >>> len(rb)
        1

    .. note::
        ``ConsumingSampler`` only supports 1-dimensional storages and uniform
        random sampling without replacement within each sampled batch.
        Prefetching and prioritized replay are not supported.
    """

    def __init__(self, max_sample_count: int = 1):
        if isinstance(max_sample_count, bool) or not isinstance(
            max_sample_count, (int, np.integer)
        ):
            raise TypeError("max_sample_count must be a positive integer.")
        if max_sample_count < 1:
            raise ValueError("max_sample_count must be a positive integer.")
        self.max_sample_count = int(max_sample_count)
        self._sample_count = None
        self._live_mask = None
        self._known_storage_len = 0
        self._free_indices = None
        self._free_head = 0
        self._ran_out = False
        self._remaining_batches = 0

    @staticmethod
    def _storage_device(storage: Storage | None):
        if storage is None:
            return None
        device = getattr(storage, "device", None)
        if device == "auto":
            return None
        return device

    def _ensure_state(
        self,
        storage: Storage | None = None,
        *,
        min_capacity: int = 0,
    ) -> None:
        if storage is not None:
            if storage.ndim != 1:
                raise ValueError(
                    f"{type(self).__name__} only supports 1-dimensional storages, "
                    f"got storage.ndim={storage.ndim}."
                )
            storage_len = len(storage)
            min_capacity = max(min_capacity, storage_len)
            device = self._storage_device(storage)
        else:
            storage_len = self._known_storage_len
            device = (
                self._sample_count.device if self._sample_count is not None else None
            )

        current_capacity = (
            0 if self._sample_count is None else self._sample_count.numel()
        )
        capacity = max(current_capacity, int(min_capacity))

        needs_init = self._sample_count is None or self._live_mask is None
        needs_resize = current_capacity < capacity
        needs_device = (
            not needs_init
            and device is not None
            and self._sample_count.device != torch.device(device)
        )

        if needs_init or needs_resize or needs_device:
            old_sample_count = self._sample_count
            old_live_mask = self._live_mask
            old_free_indices = self._free_indices
            self._sample_count = torch.zeros(capacity, dtype=torch.long, device=device)
            self._live_mask = torch.zeros(capacity, dtype=torch.bool, device=device)
            if old_sample_count is not None and old_live_mask is not None:
                copy_len = min(old_sample_count.numel(), capacity)
                self._sample_count[:copy_len] = old_sample_count[:copy_len].to(
                    self._sample_count.device
                )
                self._live_mask[:copy_len] = old_live_mask[:copy_len].to(
                    self._live_mask.device
                )
            if old_free_indices is not None:
                self._free_indices = old_free_indices.to(self._sample_count.device)

        if storage is not None:
            if storage_len > self._known_storage_len:
                self._sample_count[self._known_storage_len : storage_len] = 0
                self._live_mask[self._known_storage_len : storage_len] = True
            elif storage_len < self._known_storage_len:
                self._sample_count[storage_len : self._known_storage_len] = 0
                self._live_mask[storage_len : self._known_storage_len] = False
            self._known_storage_len = storage_len

        if self._live_mask is not None and self._sample_count is not None:
            self._live_mask &= self._sample_count < self.max_sample_count

    def _compact_free_indices(self) -> None:
        if self._free_indices is None:
            self._free_head = 0
            return
        if self._free_head:
            self._free_indices = self._free_indices[self._free_head :]
            self._free_head = 0
        if not self._free_indices.numel():
            self._free_indices = None

    def _append_free_indices(self, index: torch.Tensor) -> None:
        if index.numel() == 0:
            return
        index = index.to(self._sample_count.device, dtype=torch.long).reshape(-1)
        self._compact_free_indices()
        if self._free_indices is None:
            self._free_indices = index.clone()
        else:
            self._free_indices = torch.cat([self._free_indices, index])

    def _rebuild_free_indices(self) -> None:
        if self._live_mask is None or self._sample_count is None:
            self._free_indices = None
            self._free_head = 0
            return
        consumed_mask = ~self._live_mask[: self._known_storage_len] & (
            self._sample_count[: self._known_storage_len] >= self.max_sample_count
        )
        self._free_indices = torch.nonzero(consumed_mask, as_tuple=False).flatten()
        self._free_head = 0
        if not self._free_indices.numel():
            self._free_indices = None

    def _index_to_tensor(self, index: int | torch.Tensor | tuple) -> torch.Tensor:
        if isinstance(index, tuple):
            raise ValueError(
                f"{type(self).__name__} only supports flat 1-dimensional indices."
            )
        if _is_int(index):
            min_capacity = int(index) + 1
        else:
            index = torch.as_tensor(index, dtype=torch.long)
            min_capacity = int(index.max().item()) + 1 if index.numel() else 0
        self._ensure_state(min_capacity=min_capacity)
        if _is_int(index):
            return torch.as_tensor(
                [int(index)], dtype=torch.long, device=self._sample_count.device
            )
        return torch.as_tensor(
            index, dtype=torch.long, device=self._sample_count.device
        ).reshape(-1)

    def _mark_indices_live(self, index: int | torch.Tensor | tuple) -> None:
        index = self._index_to_tensor(index)
        if index.numel() == 0:
            return
        self._sample_count[index] = 0
        self._live_mask[index] = True
        self._known_storage_len = max(
            self._known_storage_len, int(index.max().item()) + 1
        )
        self._ran_out = False

    def add(self, index: int) -> None:
        self._mark_indices_live(index)

    def extend(self, index: torch.Tensor) -> None:
        self._mark_indices_live(index)

    def mark_update(
        self, index: int | torch.Tensor | tuple, *, storage: Storage | None = None
    ) -> None:
        if storage is not None:
            min_capacity = len(storage)
            if not _is_int(index) and not isinstance(index, tuple):
                index_tensor = torch.as_tensor(index)
                if index_tensor.numel():
                    min_capacity = max(
                        min_capacity, int(index_tensor.reshape(-1).max().item()) + 1
                    )
            elif _is_int(index):
                min_capacity = max(min_capacity, int(index) + 1)
            self._ensure_state(storage, min_capacity=min_capacity)
        self._mark_indices_live(index)

    def _num_sampleable(self, storage: Storage | None = None) -> int:
        self._ensure_state(storage)
        if self._live_mask is None:
            return 0
        return int(self._live_mask[: self._known_storage_len].sum().item())

    def _sampleable_count(self, storage: Storage, batch_size: int) -> int:
        return self._num_sampleable(storage)

    def _pop_consumed_indices(
        self, storage: Storage | None = None, max_count: int | None = None
    ) -> torch.Tensor:
        self._ensure_state(storage)
        if (
            self._live_mask is None
            or self._sample_count is None
            or self._free_indices is None
        ):
            return torch.zeros(0, dtype=torch.long)

        popped = []
        while self._free_head < self._free_indices.numel():
            if max_count is not None and len(popped) >= max_count:
                break
            candidate = self._free_indices[self._free_head]
            self._free_head += 1
            if candidate >= self._known_storage_len:
                continue
            if self._live_mask[candidate]:
                continue
            if self._sample_count[candidate] < self.max_sample_count:
                continue
            popped.append(candidate)

        if self._free_head >= self._free_indices.numel():
            self._free_indices = None
            self._free_head = 0

        if not popped:
            device = self._sample_count.device
            return torch.zeros(0, dtype=torch.long, device=device)
        return torch.stack(popped)

    def _update_remaining_batches(self, batch_size: int, storage: Storage) -> None:
        num_sampleable = self._num_sampleable(storage)
        self._remaining_batches = -(num_sampleable // -batch_size)
        self._ran_out = num_sampleable == 0

    def sample(self, storage: Storage, batch_size: int) -> tuple[torch.Tensor, dict]:
        self._ensure_state(storage)
        if len(storage) == 0 or self._num_sampleable(storage) == 0:
            self._ran_out = True
            self._remaining_batches = 0
            raise RuntimeError(_EMPTY_STORAGE_ERROR)

        live_indices = torch.nonzero(
            self._live_mask[: self._known_storage_len], as_tuple=False
        ).flatten()
        sample_size = min(batch_size, live_indices.numel())
        permutation = torch.randperm(
            live_indices.numel(),
            generator=self._rng,
            device=live_indices.device,
        )[:sample_size]
        index = live_indices[permutation]

        self._sample_count[index] += 1
        self._live_mask[index] = self._sample_count[index] < self.max_sample_count
        newly_consumed = index[~self._live_mask[index]]
        self._append_free_indices(newly_consumed)
        self._update_remaining_batches(batch_size, storage)
        return index, {}

    @property
    def ran_out(self):
        return self._ran_out

    @ran_out.setter
    def ran_out(self, value):
        self._ran_out = value

    def _empty(self):
        self._sample_count = None
        self._live_mask = None
        self._known_storage_len = 0
        self._free_indices = None
        self._free_head = 0
        self._ran_out = False
        self._remaining_batches = 0

    def dumps(self, path):
        path = Path(path)
        path.mkdir(exist_ok=True)
        torch.save(self.state_dict(), path / "consuming_sampler.pt")

    def loads(self, path):
        self.load_state_dict(torch.load(Path(path) / "consuming_sampler.pt"))

    def state_dict(self) -> dict[str, Any]:
        return OrderedDict(
            max_sample_count=self.max_sample_count,
            _sample_count=self._sample_count,
            _live_mask=self._live_mask,
            _known_storage_len=self._known_storage_len,
            _free_indices=(
                None
                if self._free_indices is None
                else self._free_indices[self._free_head :]
            ),
            _free_head=0,
            _ran_out=self._ran_out,
        )

    def load_state_dict(self, state_dict: dict[str, Any]) -> None:
        # clone incoming tensors to decouple the sampler state from the
        # caller's objects (which may e.g. be mmap-backed by a checkpoint file)
        def _clone(value):
            return value.clone() if isinstance(value, torch.Tensor) else value

        self.max_sample_count = int(state_dict["max_sample_count"])
        self._sample_count = _clone(state_dict["_sample_count"])
        self._live_mask = _clone(state_dict["_live_mask"])
        self._known_storage_len = int(state_dict["_known_storage_len"])
        self._free_indices = _clone(state_dict.get("_free_indices"))
        self._free_head = int(state_dict.get("_free_head", 0))
        self._ran_out = bool(state_dict["_ran_out"])
        if "_free_indices" not in state_dict:
            self._rebuild_free_indices()
        if self._live_mask is not None:
            num_sampleable = int(
                self._live_mask[: self._known_storage_len].sum().item()
            )
            self._remaining_batches = num_sampleable
        else:
            self._remaining_batches = 0

    def __repr__(self):
        return (
            f"{self.__class__.__name__}("
            f"max_sample_count={self.max_sample_count}, "
            f"sampleable={self._num_sampleable()})"
        )


class SamplerWithoutReplacement(Sampler):
    """A data-consuming sampler that ensures that the same sample is not present in consecutive batches.

    Args:
        drop_last (bool, optional): if ``True``, the last incomplete sample (if any) will be dropped.
            If ``False``, this last sample will be kept and (unlike with torch dataloaders)
            completed with other samples from a fresh indices permutation.
            Defaults to ``False``.
        shuffle (bool, optional): if ``False``, the items are not randomly
            permuted. This enables to iterate over the replay buffer in the
            order the data was collected. Defaults to ``True``.

    *Caution*: If the size of the storage changes in between two calls, the samples will be re-shuffled
    (as we can't generally keep track of which samples have been sampled before and which haven't).

    Similarly, it is expected that the storage content remains the same in between two calls,
    but this is not enforced.

    When the sampler reaches the end of the list of available indices, a new sample order
    will be generated and the resulting indices will be completed with this new draw, which
    can lead to duplicated indices, unless the :obj:`drop_last` argument is set to ``True``.

    """

    def __init__(self, drop_last: bool = False, shuffle: bool = True):
        self._sample_list = None
        self.len_storage = 0
        self.drop_last = drop_last
        self._ran_out = False
        self.shuffle = shuffle

    def dumps(self, path):
        path = Path(path)
        path.mkdir(exist_ok=True)

        TensorDict(self.state_dict()).memmap(path)

    def loads(self, path):
        sd = TensorDict.load_memmap(path).to_dict()
        self.load_state_dict(sd)

    def _get_sample_list(self, storage: Storage, len_storage: int, batch_size: int):
        if storage is None:
            device = self._sample_list.device
        else:
            device = storage.device if hasattr(storage, "device") else None

        if self.shuffle:
            _sample_list = torch.randperm(
                len_storage, device=device, generator=self._rng
            )
        else:
            _sample_list = torch.arange(len_storage, device=device)
        self._sample_list = _sample_list
        if self.drop_last:
            self._remaining_batches = self._sample_list.numel() // batch_size
        else:
            self._remaining_batches = -(self._sample_list.numel() // -batch_size)

    def _single_sample(self, len_storage, batch_size):
        index = self._sample_list[:batch_size]
        self._sample_list = self._sample_list[batch_size:]
        if self.drop_last:
            self._remaining_batches = self._sample_list.numel() // batch_size
        else:
            self._remaining_batches = -(self._sample_list.numel() // -batch_size)

        # check if we have enough elements for one more batch, assuming same batch size
        # will be used each time sample is called
        if self._sample_list.shape[0] == 0 or (
            self.drop_last and len(self._sample_list) < batch_size
        ):
            self.ran_out = True
            self._get_sample_list(
                storage=None, len_storage=len_storage, batch_size=batch_size
            )
        else:
            self.ran_out = False
        return index

    def _storage_len(self, storage):
        return len(storage)

[docs] def can_sample(self, storage: Storage, batch_size: int) -> bool: """Returns whether the storage can provide the requested batch.""" return len(storage) >= batch_size if self.drop_last else len(storage) > 0
def sample( self, storage: Storage, batch_size: int ) -> tuple[Any, dict]: # noqa: F811 len_storage = self._storage_len(storage) if len_storage == 0: raise RuntimeError(_EMPTY_STORAGE_ERROR) if not len_storage: raise RuntimeError("An empty storage was passed") if self.len_storage != len_storage or self._sample_list is None: self._get_sample_list(storage, len_storage, batch_size=batch_size) if len_storage < batch_size and self.drop_last: raise ValueError( f"The batch size ({batch_size}) is greater than the storage capacity ({len_storage}). " "This makes it impossible to return a sample without repeating indices. " "Consider changing the sampler class or turn the 'drop_last' argument to False." ) self.len_storage = len_storage index = self._single_sample(len_storage, batch_size) if storage.ndim > 1: index = unravel_index(index, storage.shape) # we 'always' return the indices. The 'drop_last' just instructs the # sampler to turn to `ran_out = True` whenever the next sample # will be too short. This will be read by the replay buffer # as a signal for an early break of the __iter__(). return index, {} @property def ran_out(self): return self._ran_out @ran_out.setter def ran_out(self, value): self._ran_out = value def _empty(self): self._sample_list = None self.len_storage = 0 self._ran_out = False def state_dict(self) -> dict[str, Any]: return OrderedDict( len_storage=self.len_storage, _sample_list=self._sample_list, drop_last=self.drop_last, _ran_out=self._ran_out, ) def load_state_dict(self, state_dict: dict[str, Any]) -> None: self.len_storage = int(state_dict["len_storage"]) # clone to decouple the sampler state from the caller's tensor _sample_list = state_dict["_sample_list"] self._sample_list = ( _sample_list.clone() if isinstance(_sample_list, torch.Tensor) else _sample_list ) # TensorDict checkpoints encode scalars as memory-mapped tensors. # Restore Python values so the next save is independent of that file. self.drop_last = bool(state_dict["drop_last"]) self._ran_out = bool(state_dict["_ran_out"]) def __repr__(self): if self._sample_list is not None: perc = len(self._sample_list) / self.len_storage * 100 else: perc = 0.0 return f"{self.__class__.__name__}({perc: 4.4f}% sampled)" def _default_staleness_weight(s: torch.Tensor) -> torch.Tensor: """Default freshness weighting: 1 / (staleness + 1).""" return 1.0 / (s.float() + 1.0)