Rate this Page

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

# 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 textwrap
from collections import OrderedDict
from pathlib import Path
from typing import Any, Literal

import numpy as np
import torch
from tensordict import TensorDict
from torchrl.data.replay_buffers.storages import StorageEnsemble

from .base import Sampler


class SamplerEnsemble(Sampler):
    """An ensemble of samplers.

    This class is designed to work with :class:`~torchrl.data.replay_buffers.replay_buffers.ReplayBufferEnsemble`.
    It contains the samplers as well as the sampling strategy hyperparameters.

    Args:
        samplers (sequence of Sampler): the samplers to make the composite sampler.

    Keyword Args:
        p (list, tensor of probabilities, or ``"sampleable"``, optional): if
            provided, indicates the weights of each dataset during sampling.
            ``"sampleable"`` recomputes weights from the number of records or
            valid slice windows currently available in each member and excludes
            members that cannot provide a batch.
        sample_from_all (bool, optional): if ``True``, each dataset will be sampled
            from. This is not compatible with the ``p`` argument. Defaults to ``False``.
        num_buffer_sampled (int, optional): the number of buffers to sample.
            if ``sample_from_all=True``, this has no effect, as it defaults to the
            number of buffers. If ``sample_from_all=False``, buffers will be
            sampled according to the probabilities ``p``.

    .. warning::
      The indices provided in the info dictionary are placed in a :class:`~tensordict.TensorDict` with
      keys ``index`` and ``buffer_ids`` that allow the upper :class:`~torchrl.data.ReplayBufferEnsemble`
      and :class:`~torchrl.data.StorageEnsemble` objects to retrieve the data.
      This format is different from with other samplers which usually return indices
      as regular tensors.

    """

    def __init__(
        self,
        *samplers,
        p: list[float] | torch.Tensor | Literal["sampleable"] | None = None,
        sample_from_all: bool = False,
        num_buffer_sampled: int | None = None,
    ):
        self._rng_private = None
        self._samplers = samplers
        self.sample_from_all = sample_from_all
        if sample_from_all and p is not None:
            raise RuntimeError(
                "Cannot pass both `p` argument and `sample_from_all=True`."
            )
        self.p = p
        self.num_buffer_sampled = num_buffer_sampled

    @property
    def _rng(self):
        return self._rng_private

    @_rng.setter
    def _rng(self, value):
        self._rng_private = value
        for sampler in self._samplers:
            sampler._rng = value

    @property
    def p(self):
        return self._p

    @p.setter
    def p(self, value):
        if isinstance(value, str):
            if value != "sampleable":
                raise ValueError(
                    "The only dynamic SamplerEnsemble probability is 'sampleable'."
                )
            self._p = value
            return
        if not isinstance(value, torch.Tensor) and value is not None:
            value = torch.tensor(value)
        if value is not None:
            value = value / value.sum().clamp_min(1e-6)
        self._p = value

    @property
    def num_buffer_sampled(self):
        value = self.__dict__.get("_num_buffer_sampled", None)
        if value is None:
            value = self.__dict__["_num_buffer_sampled"] = len(self._samplers)
        return value

    @num_buffer_sampled.setter
    def num_buffer_sampled(self, value):
        self.__dict__["_num_buffer_sampled"] = value

    def sample(self, storage, batch_size):
        if batch_size % self.num_buffer_sampled > 0:
            raise ValueError("The batch size must be divisible by num_buffer_sampled.")
        if not isinstance(storage, StorageEnsemble):
            raise TypeError("SamplerEnsemble requires a StorageEnsemble.")
        sub_batch_size = batch_size // self.num_buffer_sampled
        if self.sample_from_all:
            samples, infos = zip(
                *[
                    sampler.sample(storage, sub_batch_size)
                    for storage, sampler in zip(storage._storages, self._samplers)
                ]
            )
            buffer_ids = torch.arange(len(samples))
        else:
            if isinstance(self.p, str):
                counts = torch.tensor(
                    [
                        float(sampler._sampleable_count(member_storage, sub_batch_size))
                        for member_storage, sampler in zip(
                            storage._storages, self._samplers
                        )
                    ],
                    dtype=torch.float,
                    device=self._rng.device if self._rng is not None else "cpu",
                )
                if not counts.any():
                    raise RuntimeError(
                        "None of the replay-buffer ensemble members can be sampled."
                    )
                probabilities = counts / counts.sum()
                buffer_ids = torch.multinomial(
                    probabilities,
                    self.num_buffer_sampled,
                    True,
                    generator=self._rng,
                )
            elif self.p is None:
                buffer_ids = torch.randint(
                    len(self._samplers),
                    (self.num_buffer_sampled,),
                    generator=self._rng,
                    device=getattr(storage, "device", None),
                )
            else:
                buffer_ids = torch.multinomial(
                    self.p,
                    self.num_buffer_sampled,
                    True,
                    generator=self._rng,
                )
            samples = [None] * self.num_buffer_sampled
            infos = [None] * self.num_buffer_sampled
            for member_id in buffer_ids.unique(sorted=True).tolist():
                positions = (buffer_ids == member_id).nonzero().flatten()
                member_batch_size = sub_batch_size * positions.numel()
                member_samples = []
                member_infos = []
                remaining = member_batch_size
                while remaining:
                    member_sample, member_info = self._samplers[member_id].sample(
                        storage._storages[member_id], remaining
                    )
                    if not isinstance(member_sample, torch.Tensor):
                        member_sample = torch.stack(member_sample, -1)
                    if not member_sample.shape[0] or member_sample.shape[0] > remaining:
                        raise RuntimeError(
                            f"Sampler {member_id} returned {member_sample.shape[0]} "
                            f"records for a requested batch of {remaining}."
                        )
                    member_samples.append(member_sample)
                    member_infos.append(member_info)
                    remaining -= member_sample.shape[0]
                member_sample = torch.cat(member_samples)
                if len(member_infos) == 1:
                    member_info = member_infos[0]
                else:
                    member_info = {}
                    keys = set().union(*(info.keys() for info in member_infos))
                    part_sizes = [part.shape[0] for part in member_samples]
                    for key in keys:
                        values = [info.get(key) for info in member_infos]
                        if all(
                            isinstance(value, torch.Tensor)
                            and value.ndim
                            and value.shape[0] == part_size
                            for value, part_size in zip(values, part_sizes)
                        ):
                            member_info[key] = torch.cat(values)
                        else:
                            member_info[key] = next(
                                value for value in reversed(values) if value is not None
                            )
                sample_chunks = member_sample.split(sub_batch_size, dim=0)
                member_info = (
                    TensorDict.from_dict(member_info, batch_dims=0)
                    if member_info
                    else TensorDict()
                )
                for chunk_id, position in enumerate(positions.tolist()):
                    samples[position] = sample_chunks[chunk_id]
                    start = chunk_id * sub_batch_size
                    stop = start + sub_batch_size
                    info_chunk = member_info.clone(False)
                    for key, value in member_info.items(
                        include_nested=True, leaves_only=True
                    ):
                        if (
                            isinstance(value, torch.Tensor)
                            and value.ndim
                            and value.shape[0] == member_batch_size
                        ):
                            info_chunk.set(key, value[start:stop])
                    infos[position] = info_chunk
        samples = [
            sample if isinstance(sample, torch.Tensor) else torch.stack(sample, -1)
            for sample in samples
        ]
        if all(samples[0].shape == sample.shape for sample in samples[1:]):
            samples_stack = torch.stack(samples)
        else:
            samples_stack = torch.nested.nested_tensor(list(samples))

        samples = TensorDict(
            {
                "index": samples_stack,
                "buffer_ids": buffer_ids,
            },
            batch_size=[self.num_buffer_sampled],
        )
        if not isinstance(infos, list):
            infos = [
                TensorDict.from_dict(info, batch_dims=samples.ndim - 1)
                if info
                else TensorDict()
                for info in infos
            ]
        infos = torch.stack(infos)
        return samples, infos

[docs] def can_sample(self, storage: StorageEnsemble, batch_size: int) -> bool: """Returns whether the selected ensemble strategy can serve a batch.""" if batch_size % self.num_buffer_sampled: return False sub_batch_size = batch_size // self.num_buffer_sampled readiness = [ sampler.can_sample(member_storage, sub_batch_size) for member_storage, sampler in zip(storage._storages, self._samplers) ] if self.sample_from_all: return all(readiness) if isinstance(self.p, str) or self.p is None: return any(readiness) return any( ready and probability > 0 for ready, probability in zip(readiness, self.p.tolist()) )
def dumps(self, path: Path): path = Path(path).absolute() path.mkdir(parents=True, exist_ok=True) for i, sampler in enumerate(self._samplers): sampler.dumps(path / str(i)) def loads(self, path: Path): path = Path(path).absolute() for i, sampler in enumerate(self._samplers): sampler.loads(path / str(i)) def state_dict(self) -> dict[str, Any]: state_dict = OrderedDict() for i, sampler in enumerate(self._samplers): state_dict[str(i)] = sampler.state_dict() return state_dict def load_state_dict(self, state_dict: dict[str, Any]) -> None: for i, sampler in enumerate(self._samplers): sampler.load_state_dict(state_dict[str(i)]) def _empty(self): for sampler in self._samplers: sampler._empty() _INDEX_ERROR = "Expected an index of type torch.Tensor, range, np.ndarray, int, slice or ellipsis, got {} instead." def __getitem__(self, index): if isinstance(index, tuple): if index[0] is Ellipsis: index = (slice(None), index[1:]) result = self[index[0]] if len(index) > 1: raise IndexError( f"Tuple of length greater than 1 are not accepted to index samplers of type {type(self)}." ) return result if isinstance(index, slice) and index == slice(None): return self if isinstance(index, (list, range, np.ndarray)): index = torch.as_tensor(index) if isinstance(index, torch.Tensor): if index.ndim > 1: raise RuntimeError( f"Cannot index a {type(self)} with tensor indices that have more than one dimension." ) if index.is_floating_point(): raise TypeError( "A floating point index was received when an integer dtype was expected." ) if isinstance(index, int) or (not isinstance(index, slice) and len(index) == 0): try: index = int(index) except Exception: raise IndexError(self._INDEX_ERROR.format(type(index))) try: return self._samplers[index] except IndexError: raise IndexError(self._INDEX_ERROR.format(type(index))) if isinstance(index, torch.Tensor): index = index.tolist() samplers = [self._samplers[i] for i in index] else: # slice samplers = self._samplers[index] p = self._p[index] if isinstance(self._p, torch.Tensor) else self._p return SamplerEnsemble( *samplers, p=p, sample_from_all=self.sample_from_all, num_buffer_sampled=self.num_buffer_sampled, ) def __len__(self): return len(self._samplers) def __repr__(self): samplers = textwrap.indent(f"samplers={self._samplers}", " " * 4) return f"{self.__class__.__name__}(\n{samplers})"