Use tensordict with other libraries#
Many libraries accept a tensordict without any glue code. A
TensorDict is a pytree (torch.utils._pytree) and a
Mapping, and it has a
to() method. Each section of this page shows
one library, with a short example.
The examples need the other libraries, which tensordict does not install, so they are not run when this page is tested. They were run with torch 2.14, torchvision 0.29, Lightning 2.6, Accelerate 1.15, Transformers 5.20, Datasets 5.1, Pandera 0.34, JAX 0.12 and xarray 2026.9.
torchvision transforms#
The transforms of torchvision.transforms.v2 accept any pytree, so they
accept a tensordict or a tensorclass. They return the same type, and the
leaves keep their tv_tensors types. In this example, the boxes follow the
image through a flip and a resize:
>>> import torch
>>> from torchvision import tv_tensors
>>> from torchvision.transforms import v2
>>> from tensordict import TensorDict
>>> sample = TensorDict(
... image=tv_tensors.Image(torch.zeros(3, 32, 48, dtype=torch.uint8)),
... boxes=tv_tensors.BoundingBoxes(
... [[2, 4, 20, 30], [10, 10, 40, 28]], format="XYXY", canvas_size=(32, 48)
... ),
... label=torch.tensor([3, 7]),
... )
>>> transform = v2.Compose([v2.RandomHorizontalFlip(p=1.0), v2.Resize((16, 24))])
>>> out = transform(sample)
>>> type(out).__name__, type(out["image"]).__name__, out["image"].shape
('TensorDict', 'Image', torch.Size([3, 16, 24]))
>>> out["boxes"].canvas_size, out["boxes"].tolist()
((16, 24), [[14, 2, 23, 15], [4, 5, 19, 14]])
>>> out["label"]
tensor([3, 7])
Wrap the image in tv_tensors.Image. Without it, a transform takes the
first plain tensor of the tensordict as the image, in the order of the keys.
Here it flips the labels:
>>> plain = TensorDict(label=torch.tensor([3, 7]), image=torch.zeros(3, 32, 48))
>>> v2.RandomHorizontalFlip(p=1.0)(plain)["label"]
tensor([7, 3])
Keep the image dimensions out of the batch size: give one sample
batch_size=[], and a batch of images batch_size=[N]. A transform
applied to a batch draws one set of random parameters for all its images:
>>> images = TensorDict(
... image=tv_tensors.Image(torch.zeros(4, 3, 32, 48, dtype=torch.uint8)),
... label=torch.arange(4),
... batch_size=[4],
... )
>>> v2.RandomCrop((16, 16))(images)["image"].shape
torch.Size([4, 3, 16, 16])
To give each sample its own random parameters, transform the samples in the
dataset, before the batch is built. Samples for object detection have
different numbers of boxes, so torch.stack cannot stack them. Pass
lazy_stack() as the collate_fn of the
DataLoader instead (see Load data and share it between processes):
>>> from tensordict import lazy_stack
>>> one_box = TensorDict(
... image=tv_tensors.Image(torch.zeros(3, 32, 48, dtype=torch.uint8)),
... boxes=tv_tensors.BoundingBoxes([[0, 0, 8, 8]], format="XYXY", canvas_size=(32, 48)),
... label=torch.tensor([1]),
... )
>>> batch = lazy_stack([transform(sample), transform(one_box)])
>>> [boxes.shape for boxes in batch.get("boxes", as_list=True)]
[torch.Size([2, 4]), torch.Size([1, 4])]
Lightning#
A tensordict or a tensorclass can be the batch of a Lightning Trainer.
Lightning moves the batch to the device with one call to batch.to(device).
It passes non_blocking=True only to plain tensors, but you do not need to
override transfer_batch_to_device for that: by default, to() copies
each leaf with non_blocking=True (see Devices, dtypes, and memory). For copies from
pinned memory, pass pin_memory=True to the DataLoader (see
Load data and share it between processes).
>>> import lightning as L
>>> from torch.utils.data import DataLoader
>>> class Regression(L.LightningModule):
... def __init__(self):
... super().__init__()
... self.net = torch.nn.Linear(2, 1)
...
... def training_step(self, batch, batch_idx):
... pred = self.net(batch["x"]).squeeze(-1)
... loss = torch.nn.functional.mse_loss(pred, batch["y"])
... self.log("loss", loss, on_epoch=True)
... return loss
...
... def configure_optimizers(self):
... return torch.optim.SGD(self.parameters(), lr=0.1)
>>> data = TensorDict(x=torch.randn(64, 2), y=torch.randn(64), batch_size=[64])
>>> loader = DataLoader(data, batch_size=16, collate_fn=lambda x: x)
>>> trainer = L.Trainer(
... max_epochs=1,
... logger=False,
... enable_checkpointing=False,
... enable_progress_bar=False,
... enable_model_summary=False,
... )
>>> trainer.fit(Regression(), loader)
>>> sorted(trainer.callback_metrics)
['loss', 'loss_epoch', 'loss_step']
Pass batch_size to self.log when it averages a value over the epoch
(on_epoch=True, the default in validation_step) and the batch is a
tensorclass, or a tensordict that holds one non-tensor value for the whole
batch, such as a string. Lightning finds the batch size by iterating over the
batch, and over the values of a mapping. A tensorclass and a non-tensor
value iterate over their rows, and a row without batch dimensions raises a
TypeError when Lightning iterates over it in turn:
>>> from lightning.pytorch.utilities.data import extract_batch_size
>>> extract_batch_size(TensorDict(x=torch.zeros(16, 2), batch_size=[16]))
16
>>> extract_batch_size(
... TensorDict(x=torch.zeros(16, 2), name="run-1", batch_size=[16])
... )
Traceback (most recent call last):
...
TypeError: iteration over a 0-d tensordict. ...
Write, for example,
self.log("loss", loss, on_epoch=True, batch_size=batch.batch_size[0]).
Hugging Face Accelerate#
Accelerator.prepare accepts a DataLoader of tensorclasses or
tensordicts, and moves each batch to the device with batch.to(device).
In this example, a tensorclass is the dataset (see Load data and share it between processes):
>>> import tensordict
>>> from accelerate import Accelerator
>>> from tensordict import TensorClass
>>> class Sample(TensorClass):
... x: torch.Tensor
... y: torch.Tensor
... name: str
>>> data = Sample(x=torch.randn(64, 2), y=torch.randn(64), name="train", batch_size=[64])
>>> accelerator = Accelerator()
>>> model = torch.nn.Linear(2, 1)
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
>>> loader = DataLoader(data, batch_size=16, collate_fn=tensordict.collate)
>>> model, optimizer, loader = accelerator.prepare(model, optimizer, loader)
>>> for batch in loader:
... loss = torch.nn.functional.mse_loss(model(batch.x).squeeze(-1), batch.y)
... accelerator.backward(loss)
... optimizer.step()
... optimizer.zero_grad()
>>> type(batch).__name__, batch.batch_size
('Sample', torch.Size([16]))
For an IterableDataset, Accelerate uses dispatch_batches=True by
default: the main process reads each batch and sends a slice of it to each
process. Accelerate can find the batch size of tensors, lists, tuples and
dicts only, so a tensorclass or a tensordict batch then raises an error, even
in a single process. Pass dispatch_batches=False, so that each process
reads its own batches:
>>> from accelerate.utils import DataLoaderConfiguration
>>> from torch.utils.data import IterableDataset
>>> class Stream(IterableDataset):
... def __iter__(self):
... for i in range(64):
... yield Sample(x=torch.randn(2), y=torch.randn(()), name=f"sample-{i}")
>>> accelerator = Accelerator(
... dataloader_config=DataLoaderConfiguration(dispatch_batches=False)
... )
>>> stream = DataLoader(Stream(), batch_size=16, collate_fn=tensordict.collate)
>>> [batch.batch_size for batch in accelerator.prepare(stream)]
[torch.Size([16]), torch.Size([16]), torch.Size([16]), torch.Size([16])]
Gather tensors with accelerator.gather_for_metrics, such as the
predictions and the targets. Accelerate gathers a tensorclass, or a
tensordict with a non-tensor value, as a Python object: in a run with
several processes it returns a list of the rows of all the processes.
torch.stack(list(...)) makes one batch of the result, with one process
or several:
>>> gathered = torch.stack(list(accelerator.gather_for_metrics(batch)))
>>> type(gathered).__name__, gathered.batch_size
('Sample', torch.Size([16]))
A tensordict that holds only tensors is gathered one tensor at a time. In a
run with several processes, the tensordict that Accelerate returns has no
batch dimensions: auto_batch_size_() sets them
again:
>>> preds = TensorDict(x=torch.randn(16, 2), batch_size=[16])
>>> gathered = accelerator.gather_for_metrics(preds).auto_batch_size_(batch_dims=1)
>>> gathered.batch_size
torch.Size([16])
Hugging Face Transformers and Datasets#
The output of a tokenizer is a mapping of tensors with one row for each text,
so a tensordict holds it with batch_size=[len(texts)]. You can then index,
split, stack, and move the tokens as one object. model(**tokens) passes
each entry as a keyword argument, as for a dict. The model in this
example is small and untrained:
>>> from transformers import AutoTokenizer, GPT2Config, GPT2LMHeadModel
>>> tokenizer = AutoTokenizer.from_pretrained("gpt2")
>>> tokenizer.pad_token = tokenizer.eos_token
>>> texts = ["Hello world", "A longer sentence than the first"]
>>> encoded = tokenizer(texts, padding=True, return_tensors="pt")
>>> tokens = TensorDict(encoded, batch_size=[len(texts)])
>>> tokens
TensorDict(batch_size=[2], fields={attention_mask: i64[6], input_ids: i64[6]})
>>> model = GPT2LMHeadModel(GPT2Config(n_layer=1, n_head=2, n_embd=16))
>>> model(**tokens).logits.shape
torch.Size([2, 6, 50257])
>>> model(**tokens[:1]).logits.shape
torch.Size([1, 6, 50257])
A dataset of the datasets library with with_format("torch") returns
a dict of tensors for a slice of rows. A column of strings becomes a
NonTensorStack (see Non-tensor data and unbatched entries):
>>> from datasets import Dataset
>>> ds = Dataset.from_dict(
... {"x": [[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]], "label": [0, 1, 0], "text": ["a", "b", "c"]}
... ).with_format("torch")
>>> batch = TensorDict(ds[:2], batch_size=[2])
>>> batch["x"]
tensor([[0., 1.],
[2., 3.]])
>>> batch[1]["text"]
'b'
TensorDict(ds[:], batch_size=[len(ds)]) loads the whole dataset in
memory, as one tensordict that a DataLoader reads (see
Load data and share it between processes). To read batches from the dataset instead, give the
DataLoader a collate_fn that stacks the rows:
>>> loader = DataLoader(
... ds, batch_size=2, collate_fn=lambda rows: torch.stack([TensorDict(row) for row in rows])
... )
>>> [batch.batch_size for batch in loader]
[torch.Size([2]), torch.Size([1])]
A column of lists with different lengths comes out as a list of tensors, and
the tensordict stores that list as a NonTensorStack too. Pad the lists in
the dataset, or put them in a lazy stack (see Variable-size data):
>>> ragged = Dataset.from_dict({"tokens": [[1, 2, 3], [4, 5]]}).with_format("torch")
>>> seqs = lazy_stack([TensorDict(tokens=t) for t in ragged[:]["tokens"]])
>>> seqs.get("tokens", as_padded_tensor=True)
tensor([[1, 2, 3],
[4, 5, 0]])
transformers.Trainer trains on tensorclass batches with two changes.
Its default collator expects a dict for each sample, so pass
data_collator=tensordict.collate. Its default compute_loss calls
model(**inputs), which needs a mapping, so override it to pass the batch
to the model. Here the tensorclass data of the Accelerate section is the
dataset:
>>> from transformers import Trainer, TrainingArguments
>>> class Regressor(torch.nn.Module):
... def __init__(self):
... super().__init__()
... self.net = torch.nn.Linear(2, 1)
...
... def forward(self, batch):
... return torch.nn.functional.mse_loss(self.net(batch.x).squeeze(-1), batch.y)
>>> class TensorClassTrainer(Trainer):
... def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
... loss = model(inputs)
... return (loss, {"loss": loss}) if return_outputs else loss
>>> args = TrainingArguments(
... output_dir="trainer-out", max_steps=4, per_device_train_batch_size=16, report_to=[]
... )
>>> trainer = TensorClassTrainer(
... model=Regressor(), args=args, train_dataset=data, data_collator=tensordict.collate
... )
>>> trainer.train().global_step
{'train_runtime': ...}
4
Use a tensorclass for the batch, not a tensordict. The Trainer rebuilds
a mapping batch with type(batch)({...}), which gives a tensordict without
batch dimensions, and then raises because the batch looks empty.
DistributedDataParallel and pytrees#
DistributedDataParallel passes a tensorclass or a
tensordict to the forward of the module, and returns one if forward
does. Here, with one process, it trains the Regressor of the previous
section:
>>> import torch.distributed as dist
>>> dist.init_process_group(
... "gloo", init_method="tcp://127.0.0.1:29500", rank=0, world_size=1
... )
>>> ddp = torch.nn.parallel.DistributedDataParallel(Regressor())
>>> ddp(data[:16]).backward()
>>> ddp.module.net.weight.grad.shape
torch.Size([1, 2])
>>> dist.destroy_process_group()
A tensorclass is a pytree. The functions of torch.utils._pytree go
through its tensors, keep its non-tensor fields, and return the same type:
>>> import torch.utils._pytree as pytree
>>> leaves, spec = pytree.tree_flatten(data)
>>> [leaf.shape for leaf in leaves]
[torch.Size([64, 2]), torch.Size([64])]
>>> zeros = pytree.tree_map(torch.zeros_like, data)
>>> type(zeros).__name__, zeros.name
('Sample', 'train')
Validate with Pandera#
Pandera (0.34 or later) validates tensordicts and tensorclasses with the
pandera.tensordict module. A schema lists the keys, with the dtype, the
shape and the checks of each, and the batch size. None accepts any size
for a dimension. validate returns a copy of the data, or raises a
SchemaError. With inplace=True, it returns the data without a copy:
>>> import pandera.tensordict as pa
>>> from pandera import Check
>>> schema = pa.TensorDictSchema(
... keys={
... "image": pa.Tensor(dtype=torch.float32, shape=(None, 3, 8, 8)),
... "label": pa.Tensor(dtype=torch.int64, shape=(None,), checks=Check.ge(0)),
... },
... batch_size=(None,),
... )
>>> batch = TensorDict(image=torch.randn(4, 3, 8, 8), label=torch.arange(4), batch_size=[4])
>>> schema.validate(batch, inplace=True) is batch
True
>>> schema.validate(batch.float())
Traceback (most recent call last):
...
pandera.errors.SchemaError: Key 'label': expected dtype torch.int64, got torch.float32
The same schema can be written as a class, as for a dataclass, and it validates a tensorclass too:
>>> from tensordict import TensorClass
>>> class ImageSchema(pa.TensorDictModel):
... image: torch.float32 = pa.Field(shape=(None, 3, 8, 8))
... label: torch.int64 = pa.Field(shape=(None,), ge=0)
...
... class Config:
... batch_size = (None,)
>>> class Images(TensorClass):
... image: torch.Tensor
... label: torch.Tensor
>>> images = Images(image=torch.randn(4, 3, 8, 8), label=torch.tensor([0, 1, -2, 3]), batch_size=[4])
>>> ImageSchema.validate(images)
Traceback (most recent call last):
...
pandera.errors.SchemaError: Check '<Check greater_than_or_equal_to: greater_than_or_equal_to(0)>' failed for key 'label': check failed
With validate(..., lazy=True), Pandera reports every error at once, in a
SchemaErrors exception. See the Pandera documentation for the other checks.
Arrays from JAX#
A JAX simulator returns a pytree of JAX arrays. Convert each array with
torch.from_dlpack(), then build the tensordict. On the same device,
DLPack shares the memory of the array, without a copy. Pass the tensors back
to JAX the same way, with jax.numpy.from_dlpack:
>>> import jax
>>> import jax.numpy as jnp
>>> state = {"qpos": jnp.zeros((4, 7)), "done": jnp.zeros(4, dtype=bool)}
>>> td = TensorDict(jax.tree.map(torch.from_dlpack, state), batch_size=[4])
>>> td["qpos"].shape, td["done"].dtype
(torch.Size([4, 7]), torch.bool)
>>> back = jax.tree.map(jnp.from_dlpack, td.to_dict())
>>> back["qpos"].shape, back["done"].dtype
((4, 7), dtype('bool'))
Convert the arrays before you build the tensordict. A tensordict keeps a JAX array as non-tensor data, which tensor operations skip:
>>> type(TensorDict(state, batch_size=[4])["qpos"]).__name__
'ArrayImpl'
JAX arrays are immutable, but a tensor from DLPack is not, and a write into it changes the JAX array too. Clone the tensor before an in-place write.
xarray#
Convert an xarray Dataset one variable at a time. Choose the batch
dimensions, which the variables share, and transpose each variable so that
they come first. Pass their names as names (see Batch size, shapes, and indexing):
>>> import numpy as np
>>> import xarray as xr
>>> ds = xr.Dataset(
... {
... "t2m": (("time", "lat", "lon"), np.zeros((8, 4, 6), dtype=np.float32)),
... "u10": (("time", "lat", "lon"), np.zeros((8, 4, 6), dtype=np.float32)),
... "z": (("level", "time", "lat", "lon"), np.zeros((3, 8, 4, 6), dtype=np.float32)),
... "lsm": (("lat", "lon"), np.zeros((4, 6), dtype=np.float32)),
... }
... )
>>> batch_dims = ["time"]
>>> weather = TensorDict(
... {k: torch.as_tensor(ds[k].transpose(*batch_dims, ...).values) for k in ["t2m", "u10", "z"]},
... batch_size=[ds.sizes[d] for d in batch_dims],
... names=batch_dims,
... )
>>> weather["z"].shape, weather.names
(torch.Size([8, 3, 4, 6]), ['time'])
Models often take one tensor for each group of variables, with the variables
as channels. to_array("channel") stacks them along a new first
dimension, so transpose the batch dimensions in front of it:
>>> def stack(ds, variables, batch_dims):
... stacked = ds[variables].to_array("channel")
... return torch.as_tensor(stacked.transpose(*batch_dims, "channel", ...).values)
>>> groups = TensorDict(
... surface=stack(ds, ["t2m", "u10"], batch_dims),
... upper=stack(ds, ["z"], batch_dims),
... batch_size=[ds.sizes[d] for d in batch_dims],
... names=batch_dims,
... )
>>> groups["surface"].shape, groups["upper"].shape
(torch.Size([8, 2, 4, 6]), torch.Size([8, 1, 3, 4, 6]))
These rules apply:
A variable without the batch dimensions, such as
lsmhere, cannot go in the tensordict, not even in a nested tensordict withbatch_size=[]: a nested tensordict has the batch dimensions of its parent. Keep it in a separate tensordict, or expand it to the batch dimensions withtorch.as_tensor(ds["lsm"].values).expand(8, 4, 6). The expanded tensor is a view, without a copy, butclone()andmemmap()copy it in full.If the
Datasetis backed by dask,.valuesloads the data in memory.The tensordict keeps the names of the batch dimensions only. The names of the other dimensions, such as
level, and the coordinates are lost.