Rate this Page
★ ★ ★ ★ ★

Source code for torchrl.data.datasets.huggingface

# 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 importlib.util
from collections.abc import Mapping
from functools import partial
from typing import Any, TYPE_CHECKING

import numpy as np
import torch
from tensordict import (
    is_non_tensor,
    MetaData,
    NestedKey,
    NonTensorData,
    NonTensorStack,
    TensorDict,
    TensorDictBase,
    UnbatchedTensor,
)
from torch.utils.data import DataLoader, Dataset

from torchrl.data.datasets._adapter_utils import _validate_export_dtypes
from torchrl.data.replay_buffers.dataloader import tensordict_collate

if TYPE_CHECKING:
    from datasets import Dataset as HFDataset
else:
    HFDataset = Any

__all__ = ["HuggingFaceDatasetAdapter"]


_has_datasets = importlib.util.find_spec("datasets") is not None
_has_pyarrow = importlib.util.find_spec("pyarrow") is not None


def _column_keys(batch: TensorDictBase) -> dict[str, Any]:
    if not isinstance(batch, TensorDict):
        raise ValueError(
            "Hugging Face export requires dense TensorDict batches with the same "
            "keys and tensor shapes for every item in a batch."
        )
    return {
        key: _column_keys(value)
        if isinstance(value, TensorDictBase) and not is_non_tensor(value)
        else None
        for key, value in batch.items()
    }


def _collate_for_export(batch: Any, *, dtypes: dict[NestedKey, torch.dtype]) -> Any:
    _validate_export_dtypes(batch, dtypes=dtypes)
    return tensordict_collate(batch)


def _batch_to_columns(
    batch: TensorDictBase, *, prefix: tuple[str, ...] = ()
) -> dict[str, Any]:
    if not isinstance(batch, TensorDict):
        raise ValueError(
            "Hugging Face export requires dense TensorDict batches with the same "
            "keys and tensor shapes for every item in a batch."
        )
    columns = {}
    for key, value in batch.items():
        path = (*prefix, key)
        display_key = path[0] if len(path) == 1 else path
        if isinstance(value, MetaData):
            columns[key] = [value.data] * len(batch)
        elif isinstance(value, NonTensorData):
            columns[key] = value.tolist()
        elif isinstance(value, NonTensorStack):
            values = [
                item.data if isinstance(item, MetaData) else item.tolist()
                for item in value.unbind(0)
            ]
            if len(values) != len(batch):
                raise ValueError(
                    f"NonTensorStack at key {display_key!r} has {len(values)} values for "
                    f"a batch of length {len(batch)}."
                )
            columns[key] = values
        elif isinstance(value, UnbatchedTensor):
            raise ValueError(
                f"Hugging Face export does not support UnbatchedTensor at key "
                f"{display_key!r}; convert it to a batched tensor first."
            )
        elif isinstance(value, TensorDictBase):
            if is_non_tensor(value):
                raise TypeError(
                    "Hugging Face export does not support non-tensor TensorDict "
                    f"entries of type {type(value).__name__} at key {display_key!r}."
                )
            if not value.keys():
                raise ValueError(
                    "Hugging Face export does not support empty nested "
                    f"TensorDicts at key {display_key!r}. Remove the empty entry "
                    "before export."
                )
            columns[key] = _batch_to_columns(value, prefix=path)
        elif isinstance(value, torch.Tensor):
            if (
                value.layout != torch.strided
                or value.is_quantized
                or value.is_complex()
            ):
                raise TypeError(
                    "Hugging Face export does not support tensor layout or dtype "
                    f"at key {display_key!r}: {value.layout}, {value.dtype}."
                )
            if value.ndim < batch.ndim or value.shape[: batch.ndim] != batch.batch_size:
                raise ValueError(
                    f"Tensor at key {display_key!r} does not start with batch size "
                    f"{list(batch.batch_size)}."
                )
            if 0 in value.shape[1:-1]:
                raise ValueError(
                    f"Tensor at key {display_key!r} has shape {list(value.shape[1:])} "
                    "with an empty inner dimension. Hugging Face export cannot "
                    "preserve dimensions following an empty dimension."
                )
            columns[key] = value
        else:
            raise TypeError(
                f"Hugging Face export does not support entries of type "
                f"{type(value).__name__} at key {display_key!r}."
            )
    return columns


def _feature_dtype(feature: Any) -> np.dtype | None:
    while feature is not None and not isinstance(feature, Mapping):
        dtype = getattr(feature, "dtype", None)
        if dtype is not None:
            try:
                return np.dtype(dtype)
            except (TypeError, ValueError):
                # Datasets 3.x Sequence declares dtype="list" on the wrapper.
                pass
        feature = getattr(feature, "feature", None)
    return None


def _contains_null(value: Any) -> bool:
    if value is None:
        return True
    if isinstance(value, np.ndarray):
        return value.dtype.hasobject and any(
            _contains_null(item) for item in value.flat
        )
    if isinstance(value, (list, tuple)):
        return any(_contains_null(item) for item in value)
    if isinstance(value, Mapping):
        return any(_contains_null(item) for item in value.values())
    return False


def _as_numeric_array(value: Any, dtype: np.dtype | None) -> np.ndarray | None:
    """Convert numeric data without rounding uint64 or replacing nulls.

    Return None for missing data; unsupported or ragged values raise NumPy's
    conversion error. Dense arrays avoid a Python scan of their elements.
    """
    if value is None:
        return None
    array = None
    if isinstance(value, (list, tuple, np.ndarray)):
        # Integer conversion rejects None and avoids rounding large uint64 values.
        # Infer other dtypes first so None cannot become NaN or False.
        array_dtype = dtype if dtype is not None and dtype.kind in "iu" else None
        try:
            array = np.asarray(value, dtype=array_dtype)
        except (TypeError, ValueError):
            pass
        if (array is None or array.dtype.hasobject) and _contains_null(value):
            return None
    return np.asarray(array if array is not None else value, dtype=dtype)


def _array_shape(value: Any, feature: Any) -> tuple[int, ...] | None:
    dimensions = []
    while feature is not None:
        shape = getattr(feature, "shape", None)
        if shape is not None:
            dimensions.extend(
                len(value) if size is None and value is not None else size
                for size in shape
            )
            if None in dimensions:
                raise ValueError(
                    "Cannot reconstruct dynamic Hugging Face array dimensions "
                    "beneath an empty sequence."
                )
            return tuple(dimensions)
        child = getattr(feature, "feature", None)
        if child is not None:
            if value is None:
                length = getattr(feature, "length", -1)
                dimensions.append(length if length >= 0 else None)
            else:
                dimensions.append(len(value))
                value = value[0] if len(value) else None
        feature = child
    return None


def _decode_feature(value: Any, feature: Any) -> Any:
    if value is None:
        return None
    if isinstance(feature, Mapping) and isinstance(value, Mapping):
        return {
            key: _decode_feature(item, feature.get(key)) for key, item in value.items()
        }
    dtype = _feature_dtype(feature)
    try:
        array = _as_numeric_array(value, dtype)
        if array is None:
            # Dense tensors have no validity bitmap. Keep nulls and NaNs intact.
            return NonTensorData(value)
        if dtype is None:
            return value
        tensor = torch.as_tensor(array)
    except (TypeError, ValueError):
        return value
    shape = _array_shape(value, feature)
    return tensor.reshape(shape) if shape is not None else tensor


class _HuggingFaceDataset(Dataset[TensorDictBase]):
    def __init__(self, dataset: HFDataset) -> None:
        from pyarrow import ArrowInvalid

        # Read stored values without precision-changing formatters or transforms.
        # with_format returns a view and does not change the caller's dataset.
        self.dataset = dataset.with_format(None)
        self.features = self.dataset.features
        self._arrow_invalid = ArrowInvalid

    def __len__(self) -> int:
        return len(self.dataset)

    def __getitem__(self, index: int) -> TensorDictBase:
        try:
            row = self.dataset[index]
        except self._arrow_invalid as error:
            raise ValueError(
                f"Hugging Face could not decode row {index}: {error}. "
                "For nullable ArrayXD data, use nested sequence features."
            ) from error
        return TensorDict.from_dict(
            {
                key: _decode_feature(value, self.features.get(key))
                for key, value in row.items()
            },
            batch_size=[],
        )


[docs] class HuggingFaceDatasetAdapter: """Convert between TorchRL map-style datasets and Hugging Face datasets. Each item in the TorchRL dataset maps to exactly one Hugging Face row. Items must be unbatched :class:`~tensordict.TensorDict` instances. Nested keys are preserved, and tensor leaves move to CPU for Arrow storage. Import reconstructs an unbatched TensorDict using the declared Hugging Face feature types for numeric tensor dtypes. Export reads the source synchronously in batches and converts tensor columns directly to Arrow arrays. Datasets with batched reads (including TorchRL storage datasets) avoid fetching items individually. Other datasets are stacked with :func:`~torchrl.data.tensordict_collate` before column conversion. Each batch must be dense, with matching keys and tensor shapes across its items. Tensor dtypes and column keys must match between all rows, including nested keys. :class:`~tensordict.NonTensorData` entries expand across their batch dimensions, including extra nested dimensions. :class:`~tensordict.MetaData` entries are repeated once per output row; :class:`~tensordict.NonTensorStack` entries provide one value per row. :class:`~tensordict.UnbatchedTensor` entries must be converted to batched tensors before export. Non-tensor payloads must be supported by Hugging Face Datasets and Apache Arrow feature inference. Empty nested TensorDicts and tensor shapes with dimensions following an empty dimension are rejected because inferred Arrow lists cannot preserve their structure reliably. Sparse, quantized, complex and float8 tensor leaves are not supported. Bfloat16 tensors are converted to float32 for Arrow storage. Import reads stored data without applying Hugging Face formatting or transforms, leaving the caller's dataset unchanged. Numeric sequences containing nulls remain :class:`~tensordict.NonTensorData` payloads, since tensors cannot distinguish missing values from valid values. Non-tensor wrapper types, nested batch sizes and dimension names are not reconstructed; other numeric values are normalized to tensors using their feature dtypes. Declared Hugging Face array shapes are restored, including empty dimensions; unresolved dynamic dimensions beneath empty sequences are rejected. Nullable ArrayXD features that Hugging Face cannot decode are rejected; use nested sequence features to preserve nulls in arrays. Export reads are not synchronized with replay writes; stop writers when a consistent snapshot is required. All converted Arrow batches stay in memory. Loading from and publishing to the Hub remain operations of the `Hugging Face datasets library <https://huggingface.co/docs/datasets>`_. Keyword Args: batch_size (int): maximum number of source items converted at once. Reduce this for large observations to limit temporary memory use. Defaults to 1024. Examples: >>> import torch >>> from tensordict import TensorDict >>> from torchrl.data import LazyTensorStorage, ReplayBuffer >>> from torchrl.data.datasets import HuggingFaceDatasetAdapter >>> replay_buffer = ReplayBuffer(storage=LazyTensorStorage(3)) >>> _ = replay_buffer.extend( ... TensorDict({"observation": torch.arange(6).reshape(3, 2)}, [3]) ... ) >>> adapter = HuggingFaceDatasetAdapter() >>> hf_dataset = adapter.to_external( ... replay_buffer.storage.as_dataset() ... ) >>> imported = adapter.from_external(hf_dataset) >>> imported[0]["observation"] tensor([0, 1]) >>> hf_dataset.push_to_hub("organization/replay-data") # doctest: +SKIP """ def __init__(self, *, batch_size: int = 1024) -> None: if not isinstance(batch_size, int) or isinstance(batch_size, bool): raise TypeError("batch_size must be a positive integer.") if batch_size <= 0: raise ValueError("batch_size must be positive.") if not _has_datasets: raise ImportError( "HuggingFaceDatasetAdapter requires the `datasets` package. " "Install it directly or install TorchRL with the " "`offline-data` extra." ) if not _has_pyarrow: raise ImportError( "HuggingFaceDatasetAdapter requires the `pyarrow` package. " "Install it directly or install TorchRL with the " "`offline-data` extra." ) import datasets import pyarrow as pa from datasets.features.features import ( cast_to_python_objects, numpy_to_pyarrow_listarray, ) self._datasets = datasets self._pa = pa self._to_numpy = cast_to_python_objects self._numpy_to_arrow = numpy_to_pyarrow_listarray self.batch_size = batch_size def _encode_columns( self, columns: Mapping[str, Any], *, prefix: tuple[str, ...] = () ) -> dict[str, Any]: arrays = {} for key, value in columns.items(): path = (*prefix, key) if isinstance(value, Mapping): nested = self._encode_columns(value, prefix=path) arrays[key] = self._pa.StructArray.from_arrays( list(nested.values()), names=list(nested) ) elif isinstance(value, torch.Tensor): # NumPy can share CPU tensor memory. HF's Arrow converter takes # a copy so later replay writes cannot change exported values. try: arrays[key] = self._numpy_to_arrow(self._to_numpy(value)) except (TypeError, ValueError) as error: display_key = path[0] if len(path) == 1 else path raise TypeError( f"Cannot convert tensor at key {display_key!r} with " f"dtype {value.dtype} to Arrow." ) from error else: arrays[key] = ( self._datasets.Dataset.from_dict({key: value}) .data[key] .combine_chunks() ) return arrays
[docs] def to_external(self, dataset: Dataset[TensorDictBase], /) -> HFDataset: """Convert a TorchRL map-style dataset to a Hugging Face dataset. Args: dataset (Dataset[TensorDictBase]): finite map-style dataset with one unbatched TensorDict item per index. Items in each batch must have matching keys and tensor shapes. Tensor dtypes must be consistent across all rows. Returns: datasets.Dataset: materialized Hugging Face dataset. Call :meth:`datasets.Dataset.push_to_hub` to publish it. """ tables = [] column_keys = None loader = DataLoader( dataset, batch_size=self.batch_size, collate_fn=partial(_collate_for_export, dtypes={}), generator=torch.Generator(device="cpu").manual_seed(0), ) offset = 0 for batch in loader: if not isinstance(batch, TensorDictBase): raise TypeError( "Hugging Face export expects TensorDict items, " f"but got a {type(batch).__name__} batch." ) if batch.ndim != 1: raise ValueError( "Hugging Face export expects an unbatched TensorDict per item, " f"but got a batch with {batch.ndim} batch dimensions." ) keys = _column_keys(batch) if not keys: raise ValueError( "Hugging Face export requires nonempty TensorDict items." ) if column_keys is not None and keys != column_keys: raise ValueError(f"Source row {offset} has different column keys.") column_keys = keys columns = _batch_to_columns(batch.detach().cpu()) table = self._pa.Table.from_pydict(self._encode_columns(columns)) if table.num_rows != len(batch): raise ValueError( f"Source batch at row {offset} contains {len(batch)} rows, " f"but Arrow conversion produced {table.num_rows} rows." ) tables.append(table) offset += len(batch) if not tables: return self._datasets.Dataset.from_dict({}) schema = self._pa.unify_schemas( [table.schema for table in tables], promote_options="permissive" ) # HF hashes each batch and composes their fingerprints on concatenation. # Hashing the entire dataset at once would serialize all its data in memory. chunks = [self._datasets.Dataset(table.cast(schema)) for table in tables] if len(chunks) == 1: return chunks[0] return self._datasets.concatenate_datasets(chunks)
[docs] def from_external(self, dataset: HFDataset, /) -> Dataset[TensorDictBase]: """Expose a Hugging Face dataset as a TorchRL map-style dataset. Args: dataset (datasets.Dataset): finite Hugging Face dataset to adapt. Select one split before passing a ``DatasetDict`` returned by :func:`datasets.load_dataset`. Stored values are read without applying active formatters or transforms. Returns: Dataset[TensorDictBase]: lazy decoding view with one TensorDict item per Hugging Face row. """ if not isinstance(dataset, self._datasets.Dataset): raise TypeError( "`dataset` must be a Hugging Face `datasets.Dataset`, but got " f"{type(dataset).__name__}. Select a split before adapting a " "DatasetDict." ) return _HuggingFaceDataset(dataset)