# 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 torch
from tensordict import TensorDictBase
from torchrl.data.replay_buffers.replay_buffers import ReplayBuffer
from torchrl.data.replay_buffers.storages import LazyTensorStorage, Storage
[docs]
class OfflineToOnlineReplayBuffer:
"""A replay buffer combining an immutable offline dataset with a growing online buffer.
:meth:`extend` routes new experience to the online buffer only; the offline
dataset is never modified. :meth:`sample` draws **exactly**
``round(offline_fraction * batch_size)`` transitions from the offline
dataset and the remainder from the online buffer, concatenated into a flat
``[batch_size]`` TensorDict.
The split is deterministic per batch (not merely correct in expectation),
so ``offline_fraction`` is honored on every single :meth:`sample` call.
When the online buffer is empty (i.e. before any :meth:`extend` call), or
once ``offline_fraction`` has been annealed to 0, :meth:`sample` draws from
a single buffer only.
.. note:: Offline and online data must share a compatible key structure so
the two sampled batches can be concatenated. This is automatic when
both come from the same environment (TED format).
Args:
offline_dataset (str or ReplayBuffer): an offline dataset object (e.g.
:class:`~torchrl.data.datasets.MinariExperienceReplay`) or a
prefixed ID string such as ``"minari:mujoco/hopper/expert-v0"`` or
``"d4rl:halfcheetah-medium-v2"`` resolved via
:func:`~torchrl.data.datasets.load_dataset`.
Keyword Args:
online_storage (Storage, optional): storage backend for the online
buffer. Mutually exclusive with ``online_capacity``.
online_capacity (int, optional): shorthand that creates a
:class:`~torchrl.data.LazyTensorStorage` of this size.
Mutually exclusive with ``online_storage``.
offline_fraction (float, optional): fraction of each batch drawn from
the offline dataset. Must be in ``(0, 1)``. Default: ``0.5``.
batch_size (int, optional): default batch size for :meth:`sample`. Required
when ``offline_dataset`` is a string, and forwarded to the dataset
constructor.
transform (Callable, optional): applied to the concatenated sample
batch on the read side.
**dataset_kwargs: forwarded to the dataset constructor when
``offline_dataset`` is a string.
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.data import (
... OfflineToOnlineReplayBuffer, ReplayBuffer, LazyTensorStorage)
>>> offline = ReplayBuffer(storage=LazyTensorStorage(1000))
>>> _ = offline.extend(TensorDict({"observation": torch.randn(1000, 4)}, [1000]))
>>> rb = OfflineToOnlineReplayBuffer(
... offline_dataset=offline,
... online_capacity=500,
... offline_fraction=0.5,
... batch_size=32,
... )
>>> _ = rb.extend(TensorDict({"observation": torch.randn(10, 4)}, [10]))
>>> rb.sample(32).batch_size
torch.Size([32])
"""
def __init__(
self,
offline_dataset,
*,
online_storage: Storage | None = None,
online_capacity: int | None = None,
offline_fraction: float = 0.5,
batch_size: int | None = None,
transform=None,
**dataset_kwargs,
):
if online_storage is not None and online_capacity is not None:
raise ValueError("Provide online_storage OR online_capacity, not both.")
if online_storage is None and online_capacity is None:
raise ValueError("Provide one of online_storage or online_capacity.")
if not (0.0 < offline_fraction < 1.0):
raise ValueError(
f"offline_fraction must be in (0, 1), got {offline_fraction}."
)
# Resolve offline dataset from string if needed
if isinstance(offline_dataset, str):
from torchrl.data.datasets.utils import load_dataset
if "batch_size" not in dataset_kwargs:
if batch_size is None:
raise ValueError(
"batch_size must be provided when offline_dataset is a "
"string, so the dataset can be constructed."
)
dataset_kwargs["batch_size"] = batch_size
offline_dataset = load_dataset(offline_dataset, **dataset_kwargs)
elif dataset_kwargs:
raise ValueError(
"dataset_kwargs are only forwarded when offline_dataset is a "
"string. Pass them directly to your dataset constructor instead."
)
# Build online buffer
if online_capacity is not None:
online_storage = LazyTensorStorage(online_capacity)
online_rb = ReplayBuffer(storage=online_storage)
self._offline_buffer = offline_dataset
self._online_buffer = online_rb
# Current fraction may be lowered by anneal(); base fraction is the
# value we anneal away from.
self._offline_fraction = offline_fraction
self._base_offline_fraction = offline_fraction
self._batch_size = batch_size
self._transform = transform
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
[docs]
def extend(self, data) -> torch.Tensor:
"""Add new online experience to the online buffer.
Args:
data: a TensorDict (or compatible sequence) to add.
Returns:
Indices at which the data was stored in the online buffer.
"""
return self._online_buffer.extend(data)
[docs]
def sample(self, batch_size: int | None = None) -> TensorDictBase:
"""Sample a flat ``[batch_size]`` batch split between the two buffers.
Draws ``round(offline_fraction * batch_size)`` from the offline dataset
and the rest from the online buffer. Falls back to a single buffer
when the online buffer is empty or the offline split rounds to 0.
Args:
batch_size (int, optional): number of samples to draw. Falls back
to the ``batch_size`` set in ``__init__``.
Returns:
TensorDictBase with batch size ``[batch_size]``.
"""
if batch_size is None:
batch_size = self._batch_size
if batch_size is None:
raise ValueError(
"batch_size must be provided either in __init__ or sample()."
)
n_offline = round(self._offline_fraction * batch_size)
n_online = batch_size - n_offline
online_empty = len(self._online_buffer) == 0
if online_empty or n_offline >= batch_size:
out = self._offline_buffer.sample(batch_size)
elif n_offline == 0:
out = self._online_buffer.sample(batch_size)
else:
offline_batch = self._offline_buffer.sample(n_offline)
online_batch = self._online_buffer.sample(n_online)
out = torch.cat([offline_batch, online_batch], dim=0)
if self._transform is not None:
out = self._transform(out)
return out
[docs]
def anneal(self, step: int, total_steps: int) -> None:
"""Linearly decay ``offline_fraction`` toward 0 over ``total_steps``.
Call once per training iteration to gradually shift the sampling
distribution from offline-dominant to purely online. Clamps at 0 for
``step >= total_steps``.
Args:
step (int): current training step (0-indexed).
total_steps (int): step at which ``offline_fraction`` reaches 0.
"""
self._offline_fraction = self._base_offline_fraction * max(
0.0, 1.0 - step / total_steps
)
@property
def offline_fraction(self) -> float:
"""The current offline sampling fraction (after any annealing)."""
return self._offline_fraction
@property
def offline_buffer(self):
"""The immutable offline dataset."""
return self._offline_buffer
@property
def online_buffer(self) -> ReplayBuffer:
"""The mutable online replay buffer."""
return self._online_buffer
def __len__(self) -> int:
return len(self._offline_buffer) + len(self._online_buffer)
def __repr__(self) -> str:
return (
f"{type(self).__name__}("
f"offline={type(self._offline_buffer).__name__}, "
f"offline_fraction={self._offline_fraction:.3f}, "
f"online_size={len(self._online_buffer)}, "
f"batch_size={self._batch_size})"
)
[docs]
def prefill_replay_buffer(
rb: ReplayBuffer,
dataset: str | ReplayBuffer,
n_samples: int | None = None,
chunk_size: int = 1000,
) -> ReplayBuffer:
"""Copy samples from an offline dataset into a mutable replay buffer.
A simpler alternative to :class:`OfflineToOnlineReplayBuffer` for users
who want a single flat buffer (no per-batch sampling ratio, slightly higher
memory usage since offline data is copied).
Args:
rb (ReplayBuffer): a mutable replay buffer to seed.
dataset (str or ReplayBuffer): offline dataset or a prefixed ID string
(``"minari:..."`` / ``"d4rl:..."``).
n_samples (int, optional): maximum number of samples to copy.
Defaults to the full dataset.
chunk_size (int, optional): number of samples copied per iteration.
When ``dataset`` is a string, this is also used as the dataset
constructor batch size. Default: ``1000``.
Returns:
ReplayBuffer: ``rb`` mutated in-place (also returned for chaining).
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.data import ReplayBuffer, LazyTensorStorage
>>> from torchrl.data.replay_buffers.offline_to_online import (
... prefill_replay_buffer)
>>> dataset = ReplayBuffer(storage=LazyTensorStorage(500))
>>> _ = dataset.extend(TensorDict({"obs": torch.randn(500, 4)}, [500]))
>>> online_rb = ReplayBuffer(storage=LazyTensorStorage(10_000))
>>> _ = prefill_replay_buffer(online_rb, dataset, n_samples=200)
>>> len(online_rb)
200
"""
if isinstance(dataset, str):
from torchrl.data.datasets.utils import load_dataset
dataset = load_dataset(dataset, batch_size=chunk_size)
total = min(n_samples, len(dataset)) if n_samples is not None else len(dataset)
copied = 0
while copied < total:
this_chunk = min(chunk_size, total - copied)
data = dataset.sample(this_chunk)
rb.extend(data)
copied += this_chunk
return rb