# 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.
"""TRL Interoperability Adapters for TorchRL.
This module provides thin adapters for interoperability between
TorchRL and Hugging Face ``trl``:
* :class:`TorchRLBufferDataset` — wraps a :class:`~torchrl.data.ReplayBuffer` as a
``torch.utils.data.IterableDataset`` and can expose a Hugging Face
:class:`datasets.IterableDataset` for trainers that require the
``datasets`` interface.
* :class:`HFRewardModelWrapper` — wraps a Hugging Face reward model (e.g., one
trained via ``trl.RewardTrainer``) as a
:class:`~tensordict.nn.TensorDictModuleBase` so it can be plugged into any
TorchRL training loop, including GRPO recipes, without any boilerplate.
Importing this module does not require ``datasets``, ``transformers``, or
``trl``. The optional ``datasets`` dependency is checked only when
:meth:`TorchRLBufferDataset.as_hf_dataset` is called.
"""
from __future__ import annotations
import contextlib
import importlib.util
from collections.abc import Iterator
from typing import Any, TYPE_CHECKING
import torch
from tensordict import NonTensorData, NonTensorStack, TensorDictBase
from tensordict.nn import TensorDictModuleBase
from tensordict.utils import NestedKey
from torch import nn
from torchrl._utils import logger as torchrl_logger
from torchrl.data.replay_buffers import ReplayBuffer
_has_datasets = importlib.util.find_spec("datasets") is not None
if TYPE_CHECKING:
from datasets import IterableDataset as HFIterableDataset
else:
HFIterableDataset = Any
__all__ = [
"HFRewardModelWrapper",
"TorchRLBufferDataset",
]
# ---------------------------------------------------------------------------
# TorchRLBufferDataset — TorchRL -> TRL
# ---------------------------------------------------------------------------
[docs]
class TorchRLBufferDataset(torch.utils.data.IterableDataset):
"""An :class:`torch.utils.data.IterableDataset` backed by a TorchRL :class:`~torchrl.data.ReplayBuffer`.
The PyTorch dataset can be consumed directly by
:class:`transformers.Trainer`. Trainers such as :class:`trl.GRPOTrainer`
that require a Hugging Face :class:`datasets.IterableDataset` can consume
the object returned by :meth:`as_hf_dataset`.
Each sampling call draws ``batch_size`` entries from the replay buffer and
yields them individually as flat ``dict[str, Any]`` objects. By default an
iterator samples one replay batch. Set ``num_batches=None`` for an
unbounded online stream; consumers of such a stream must impose their own
step limit.
.. note::
This class implements :class:`torch.utils.data.IterableDataset` (no
``__len__``), which is the safest choice for online / infinite replay
buffers. If you need a finite dataset with a known length, iterate
for a fixed number of steps yourself and collect the results.
Args:
replay_buffer (:class:`~torchrl.data.ReplayBuffer`): the TorchRL
replay buffer to wrap.
batch_size (int): number of samples to draw from the buffer per
internal sampling call. Each yielded item is one *individual*
sample (no leading batch dimension).
Keyword Args:
keys (list of :class:`~tensordict.NestedKey`, optional): if provided,
only these keys are included in the yielded dicts. Nested keys
are serialised as ``"key0.key1"`` strings so they remain
compatible with HuggingFace collators. Defaults to ``None``
(all leaf keys, with nested keys flattened).
device (torch.device or str, optional): if provided, all tensors are
moved to this device before yielding. Defaults to ``None``
(tensors stay on their current device).
num_batches (int or None, optional): number of replay batches sampled
by each iterator. ``None`` produces an unbounded stream. Defaults
to ``1``.
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.data import ReplayBuffer, ListStorage
>>> from torchrl.modules.llm.trl_interop import TorchRLBufferDataset
>>>
>>> rb = ReplayBuffer(storage=ListStorage(100), batch_size=4)
>>> for _ in range(10):
... _ = rb.add(TensorDict(
... {"input_ids": torch.randint(0, 100, (8,)),
... "attention_mask": torch.ones(8, dtype=torch.long)},
... batch_size=[],
... ))
>>>
>>> dataset = TorchRLBufferDataset(rb, batch_size=4)
>>> sample = next(iter(dataset))
>>> sample["input_ids"].shape
torch.Size([8])
.. seealso::
:class:`HFRewardModelWrapper` for the reverse direction (TRL -> TorchRL).
"""
def __init__(
self,
replay_buffer: ReplayBuffer,
batch_size: int,
*,
keys: list[NestedKey] | None = None,
device: torch.device | str | None = None,
num_batches: int | None = 1,
) -> None:
if not isinstance(replay_buffer, ReplayBuffer):
raise TypeError(
f"replay_buffer must be a torchrl ReplayBuffer instance, "
f"got {type(replay_buffer).__name__}."
)
if not isinstance(batch_size, int) or batch_size <= 0:
raise ValueError(
f"batch_size must be a positive integer, got {batch_size!r}."
)
if num_batches is not None and (
not isinstance(num_batches, int) or num_batches <= 0
):
raise ValueError(
f"num_batches must be a positive integer or None, got {num_batches!r}."
)
self._replay_buffer = replay_buffer
self._batch_size = batch_size
self._keys = keys
self._device = torch.device(device) if device is not None else None
self._num_batches = num_batches
# ------------------------------------------------------------------
# IterableDataset protocol
# ------------------------------------------------------------------
def __iter__(self) -> Iterator[dict[str, Any]]:
"""Sample replay batches and yield individual, flattened samples."""
batch_index = 0
while self._num_batches is None or batch_index < self._num_batches:
batch: TensorDictBase = self._replay_buffer.sample(self._batch_size)
batch_keys: list[NestedKey] = []
for key in batch.keys(include_nested=True):
value = batch.get(key)
if not isinstance(value, TensorDictBase) or isinstance(
value, NonTensorStack
):
batch_keys.append(key)
if self._keys is not None:
key_list = self._keys
else:
key_list = batch_keys
available_keys: list[NestedKey] = []
batch_key_set = set(batch_keys)
for key in key_list:
if key in batch_key_set:
available_keys.append(key)
else:
torchrl_logger.warning(
f"TorchRLBufferDataset: key {key!r} not found in sampled "
"TensorDict -- skipping."
)
for i in range(batch.batch_size[0]):
sample_td = batch[i]
out: dict[str, Any] = {}
for key in available_keys:
value = sample_td.get(key)
if value is None:
continue
if isinstance(value, NonTensorData):
value = value.data
elif self._device is not None and hasattr(value, "to"):
value = value.to(self._device)
if isinstance(key, str):
str_key: str = key
else:
str_key = ".".join(str(k) for k in key)
out[str_key] = value
yield out
batch_index += 1
[docs]
def as_hf_dataset(self) -> HFIterableDataset:
"""Return a Hugging Face iterable dataset backed by this adapter.
The returned object is accepted by current ``trl`` trainers, which
require :class:`datasets.Dataset` or
:class:`datasets.IterableDataset` rather than a PyTorch iterable
dataset. The replay samples must still contain the schema required by
the selected trainer, such as a top-level ``"prompt"`` field for
:class:`trl.GRPOTrainer`.
Returns:
A :class:`datasets.IterableDataset` that yields the same samples
as this adapter.
Raises:
ImportError: if the optional ``datasets`` package is unavailable.
"""
if not _has_datasets:
raise ImportError(
"TorchRLBufferDataset.as_hf_dataset requires the optional "
"'datasets' dependency. Install it with `pip install datasets`."
)
from datasets import IterableDataset
return IterableDataset.from_generator(self.__iter__)
def __repr__(self) -> str:
keys_repr = self._keys if self._keys is not None else "<all>"
return (
f"{self.__class__.__name__}("
f"replay_buffer={self._replay_buffer!r}, "
f"batch_size={self._batch_size}, "
f"keys={keys_repr}, "
f"device={self._device}, "
f"num_batches={self._num_batches})"
)
# ---------------------------------------------------------------------------
# HFRewardModelWrapper — TRL -> TorchRL
# ---------------------------------------------------------------------------
class _HFRewardModule(nn.Module):
"""Inner nn.Module that calls the HF reward model and returns a scalar reward tensor.
This is kept separate from :class:`HFRewardModelWrapper` so that
:class:`~tensordict.nn.TensorDictModuleBase` controls all TensorDict
bookkeeping while this class focuses purely on the HF model call.
"""
def __init__(
self,
model: nn.Module,
*,
inference_mode: bool,
) -> None:
super().__init__()
self.model = model
self._inference_mode = inference_mode
def forward(
self,
input_ids: torch.Tensor,
attention_mask: torch.Tensor | None,
) -> torch.Tensor:
"""Call the HF reward model and return a 1-D reward tensor of shape ``[B]``."""
if self._inference_mode:
ctx = torch.no_grad()
else:
ctx = contextlib.nullcontext()
with ctx:
if attention_mask is None:
out = self.model(input_ids=input_ids)
else:
out = self.model(
input_ids=input_ids,
attention_mask=attention_mask,
)
# HF reward models typically return a ModelOutput with a ``logits``
# attribute of shape [B, 1] or [B]. Handle both shapes.
if hasattr(out, "logits"):
rewards: torch.Tensor = out.logits
elif hasattr(out, "rewards"):
rewards = out.rewards
elif isinstance(out, torch.Tensor):
rewards = out
else:
raise RuntimeError(
"HFRewardModelWrapper: could not extract reward from model output. "
f"Got type {type(out).__name__}. Expected an output with a 'logits' "
"or 'rewards' attribute, or a bare torch.Tensor."
)
expected_shape = input_ids.shape[:-1]
if rewards.shape == expected_shape + (1,):
rewards = rewards.squeeze(-1)
elif rewards.shape != expected_shape:
raise RuntimeError(
"HFRewardModelWrapper: expected one scalar reward per input "
f"with shape {expected_shape} or {expected_shape + (1,)}, got "
f"{rewards.shape}."
)
return rewards.float()
[docs]
class HFRewardModelWrapper(TensorDictModuleBase):
"""A :class:`~tensordict.nn.TensorDictModuleBase` that wraps a Hugging Face reward model.
This adapter allows any HuggingFace sequence-classification / reward model
(e.g., one trained via ``trl.RewardTrainer`` or
``transformers.AutoModelForSequenceClassification``) to be used as a reward
signal inside a TorchRL training loop, including GRPO / PPO recipes.
On :meth:`forward`, the wrapper:
1. Reads ``token_key`` (input token ids) and ``attention_mask_key`` from
the incoming :class:`~tensordict.TensorDictBase`.
2. Calls the wrapped HF model with ``input_ids`` and ``attention_mask``.
3. Extracts the scalar reward (``logits`` or ``rewards`` attribute, squeezed
to shape ``[B]``).
4. Writes the reward to ``reward_key`` in the output TensorDict.
Args:
model (nn.Module): a Hugging Face reward model. Typically an instance
of ``AutoModelForSequenceClassification`` or any model whose
``forward`` accepts ``input_ids`` + ``attention_mask`` and returns
an output with a ``logits`` attribute of shape ``[B, 1]`` or ``[B]``.
Keyword Args:
token_key (:class:`~tensordict.NestedKey`, optional): key from which
``input_ids`` are read. Defaults to ``("tokens", "full")``,
matching the :class:`~torchrl.modules.llm.policies.Tokens` layout
used by TorchRL LLM wrappers.
attention_mask_key (:class:`~tensordict.NestedKey` or ``None``, optional):
key from which the attention mask is read. Pass ``None`` to omit
the attention mask (model must support this). Defaults to
``("masks", "all_attention_mask")``.
reward_key (:class:`~tensordict.NestedKey`, optional): key under which
the scalar reward tensor (shape ``[B]``) is written. Defaults to
``"reward"``.
inference_mode (bool, optional): if ``True``, the model forward pass is
wrapped in :func:`torch.inference_mode`, which disables gradient
computation and is more memory-efficient. Set to ``False`` (the
default) when you need gradients (e.g. for a differentiable critic
loss in PPO / GRPO). Defaults to ``False``.
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.modules.llm.trl_interop import HFRewardModelWrapper
>>>
>>> class DummyRewardModel(torch.nn.Module):
... def forward(self, input_ids, attention_mask=None):
... class Out:
... logits = torch.randn(input_ids.shape[0], 1)
... return Out()
>>>
>>> wrapper = HFRewardModelWrapper(DummyRewardModel())
>>> td = TensorDict(
... {
... "tokens": {"full": torch.randint(0, 1000, (2, 16))},
... "masks": {"all_attention_mask": torch.ones(2, 16, dtype=torch.long)},
... },
... batch_size=[2],
... )
>>> result = wrapper(td)
>>> result["reward"].shape
torch.Size([2])
.. seealso::
:class:`TorchRLBufferDataset` for the reverse direction (TorchRL -> TRL).
"""
def __init__(
self,
model: nn.Module,
*,
token_key: NestedKey = ("tokens", "full"),
attention_mask_key: NestedKey | None = ("masks", "all_attention_mask"),
reward_key: NestedKey = "reward",
inference_mode: bool = False,
) -> None:
in_keys: list[NestedKey] = [token_key]
if attention_mask_key is not None:
in_keys.append(attention_mask_key)
out_keys: list[NestedKey] = [reward_key]
super().__init__()
self.in_keys = in_keys
self.out_keys = out_keys
self._token_key = token_key
self._attention_mask_key = attention_mask_key
self._reward_key = reward_key
self._reward_module = _HFRewardModule(model, inference_mode=inference_mode)
# ------------------------------------------------------------------
# Public accessors
# ------------------------------------------------------------------
@property
def model(self) -> nn.Module:
"""The underlying HF reward model."""
return self._reward_module.model
# ------------------------------------------------------------------
# TensorDictModuleBase forward
# ------------------------------------------------------------------
[docs]
def forward(self, tensordict: TensorDictBase) -> TensorDictBase:
"""Read tokens from ``tensordict``, call the reward model, write reward back.
Args:
tensordict (:class:`~tensordict.TensorDictBase`): input data.
Must contain the keys specified by ``token_key`` and, if not
``None``, ``attention_mask_key``.
Returns:
The same (modified in-place) :class:`~tensordict.TensorDictBase`
with the scalar reward tensor written to ``reward_key``.
"""
input_ids: torch.Tensor = tensordict.get(self._token_key)
if input_ids is None:
raise KeyError(
f"HFRewardModelWrapper: token_key {self._token_key!r} not found "
"in the input TensorDict."
)
attention_mask: torch.Tensor | None = None
if self._attention_mask_key is not None:
attention_mask = tensordict.get(self._attention_mask_key, default=None)
if attention_mask is None:
raise KeyError(
"HFRewardModelWrapper: attention_mask_key "
f"{self._attention_mask_key!r} not found in the input TensorDict."
)
reward = self._reward_module(input_ids, attention_mask)
tensordict.set(self._reward_key, reward)
return tensordict