Stacking and variable-size data#
torch.stack() and torch.cat() join tensordicts as they join
tensors, so a list of samples becomes one batch in one call. A lazy stack also
joins tensordicts whose keys or shapes differ, such as graphs with different
numbers of nodes or sequences of different lengths.
Dense stacking: torch.stack and torch.cat#
torch.stack() puts tensordicts along a new batch dimension.
torch.cat() joins them along an existing batch dimension. Here, each
tensordict holds the 3-D positions of 2 particles:
>>> import torch
>>> from tensordict import TensorDict
>>> a = TensorDict({"pos": torch.zeros(2, 3)}, batch_size=[2])
>>> b = TensorDict({"pos": torch.ones(2, 3)}, batch_size=[2])
>>> stacked = torch.stack([a, b])
>>> type(stacked).__name__, stacked.batch_size
('TensorDict', torch.Size([2, 2]))
>>> torch.stack([a, b], dim=1)["pos"].shape
torch.Size([2, 2, 3])
>>> torch.cat([a, b]).batch_size
torch.Size([4])
Both return a regular TensorDict, with one new tensor for
each key. This is a dense stack: it copies the data, so a later change to the
result does not change the inputs. tensordict.stack() and
tensordict.cat() are the same functions.
torch.stack raises a RuntimeError if the tensordicts do not have the
same keys, the same batch size, and the same shape for each entry.
torch.cat accepts different sizes along the joined dimension, but it
raises a KeyError if the keys differ:
>>> c = TensorDict({"pos": torch.zeros(2, 2)}, batch_size=[2]) # 2-D positions
>>> torch.stack([a, c])
Traceback (most recent call last):
...
RuntimeError: The shapes of the tensors to stack is incompatible: torch.Size([2, 2]) vs torch.Size([2, 3]) for key pos.
>>> torch.stack([a, TensorDict({"vel": torch.zeros(2, 3)}, batch_size=[2])])
Traceback (most recent call last):
...
RuntimeError: The sets of keys in the tensordicts to stack are exclusive. Consider using `LazyStackedTensorDict.maybe_dense_stack` instead.
For tensordicts that differ in these ways, use a lazy stack.
Lazy stacks#
A lazy stack is a LazyStackedTensorDict. It keeps the list
of the original tensordicts, and it stacks their entries only when you read
them. lazy_stack() always returns one. In this example, each
tensordict holds 10 time steps of one robot:
>>> from tensordict import lazy_stack
>>> tds = [
... TensorDict({"battery": torch.zeros(10), "joints": torch.zeros(10, 4)}, batch_size=[10])
... for _ in range(3)
... ]
>>> stacked = lazy_stack(tds) # no data is stacked here
>>> type(stacked).__name__, stacked.batch_size
('LazyStackedTensorDict', torch.Size([3, 10]))
>>> stacked["battery"].shape # stacks the "battery" entries only
torch.Size([3, 10])
You also get a lazy stack from torch.stack() or torch.cat() of lazy
stacks, and from maybe_dense_stack() if the inputs cannot
make a dense stack. The table in Which function to use lists each
case.
Read and write a lazy stack#
A lazy stack reads and writes the original tensordicts:
An index along the stack dimension returns the original tensordict.
A write of an entry, or an in-place change, goes to the original tensordicts. A new key goes to each of them.
Any other index returns a new lazy stack, for example
stacked[:, 0].
For example:
>>> stacked[0] is tds[0]
True
>>> stacked["battery"] = torch.ones(3, 10)
>>> tds[0]["battery"].sum()
tensor(10.)
Each read of an entry stacks the values again, and allocates a new tensor. If
you read the same keys many times, call
contiguous() once and read from its
result. contiguous() returns a regular TensorDict that owns its data, so a
write to it does not change the original tensordicts:
>>> dense = stacked.contiguous()
>>> type(dense).__name__
'TensorDict'
>>> dense["battery"] += 1
>>> tds[0]["battery"].sum() # not changed
tensor(10.)
to_tensordict() does the same.
contiguous() raises a RuntimeError if the stacked entries have
different shapes. See Variable-size data for that case.
A lazy stack can name its stack dimension, with stack_dim_name in
lazy_stack(), or by setting its names. If the stacked
tensordicts have no names, contiguous(), to_tensordict() and
pad() give a TensorDict without that name. clone(),
which returns a lazy stack, and
densify() keep it. To keep it in a
TensorDict, call densify(), or name the dimension again with
refine_names():
>>> named = lazy_stack(tds, stack_dim_name="robot")
>>> named.names
['robot', None]
>>> named.contiguous().names
[None, None]
>>> named.contiguous().refine_names("robot", ...).names
['robot', None]
>>> named.densify().names
['robot', None]
>>> named.clone().names
['robot', None]
If the stacked tensordicts have names, all of these methods keep every name.
Note
Only stacking is lazy. Shape operations such as squeeze, unsqueeze,
permute, and view return a regular TensorDict. The legacy lazy
versions of these operations exist only with
set_lazy_legacy() set to True. This mode is deprecated
and will be removed in tensordict 0.17.
Different keys and batch sizes#
A lazy stack accepts tensordicts with different keys. The keys that are not in
every tensordict are exclusive keys. keys() does not list them, and you
cannot read them from the stack. Read them from one tensordict of the stack
instead:
>>> mixed = lazy_stack([
... TensorDict({"pos": torch.zeros(3)}, batch_size=[3]),
... TensorDict({"pos": torch.zeros(3), "charge": torch.zeros(3)}, batch_size=[3]),
... ])
>>> list(mixed.keys())
['pos']
>>> mixed[1]["charge"].shape
torch.Size([3])
A lazy stack also accepts tensordicts with different batch sizes, if they have
the same number of batch dimensions. The batch size then shows -1 for each
dimension that differs. Here, two systems have 2 and 4 particles:
>>> ragged = lazy_stack([
... TensorDict({"pos": torch.zeros(2, 3)}, batch_size=[2]),
... TensorDict({"pos": torch.zeros(4, 3)}, batch_size=[4]),
... ])
>>> ragged.batch_size
torch.Size([2, -1])
>>> ragged[1].batch_size
torch.Size([4])
Which function to use#
The table gives the type that each function returns, for a list of two
tensordicts of each kind. “Lazy stack” means a
LazyStackedTensorDict, and “raises” means that the
function raises an error.
Inputs |
|
|
|
|
|
|---|---|---|---|---|---|
Same keys and shapes |
|
lazy stack |
|
|
|
Entry shapes differ |
raises |
lazy stack |
lazy stack |
raises |
raises, unless only the |
Keys differ |
raises |
lazy stack |
lazy stack |
raises |
raises |
Batch sizes differ (same number of dimensions) |
raises |
lazy stack, |
lazy stack, |
|
|
Tensorclass instances |
the tensorclass |
the tensorclass |
the tensorclass |
the tensorclass |
the tensorclass |
Lazy stacks |
lazy stack |
lazy stack |
lazy stack |
lazy stack |
|
Use these rules to choose:
If the inputs always have the same keys and shapes, use
torch.stack. It gives a regular TensorDict: a read returns the stored tensor, without a new stack.If you must write back into the inputs, or if the keys or shapes can differ, use
lazy_stack.If the inputs usually match but sometimes do not, use
maybe_dense_stack. It gives a dense stack when it can, and a lazy stack otherwise.
Variable-size data#
Some data does not have the same shape for each element: sequences of different lengths, graphs with different numbers of nodes, strings. A dense TensorDict cannot hold it, because each entry is one tensor.
Lazy stacks#
A lazy stack holds tensordicts whose entries have different shapes. The shape
of such an entry shows -1 for each dimension that differs. stacked[key]
then raises a RuntimeError. To read the entry, pass one of these keyword
arguments to get():
as_list=Truereturns a list with one tensor for each element.as_padded_tensor=Truereturns one tensor, padded to the longest element. Set the padding withpadding_value(default0.0) andpadding_side("right"or"left").as_nested_tensor=Truereturns a nested tensor (torch.nested). The default layout istorch.jagged. Set another one withlayout.
A dense tensordict returns the stored tensor for as_list=True and
as_nested_tensor=True, and warns. Starting with TensorDict 0.17, the
default will change to returning list(tensor.unbind(0)) and a nested
tensor, as a lazy stack does.
For example, with three graphs of 2, 3, and 1 nodes, and 2 features for each node:
>>> _ = torch.manual_seed(0)
>>> graphs = lazy_stack([TensorDict({"nodes": torch.randn(n, 2)}) for n in [2, 3, 1]])
>>> graphs
LazyStackedTensorDict(batch_size=[3], stack_dim=0, fields={nodes: f32[-1,2]})
>>> [t.shape for t in graphs.get("nodes", as_list=True)]
[torch.Size([2, 2]), torch.Size([3, 2]), torch.Size([1, 2])]
>>> graphs.get("nodes", as_padded_tensor=True).shape
torch.Size([3, 3, 2])
>>> graphs.get("nodes", as_nested_tensor=True).is_nested
True
>>> graphs[1]["nodes"].shape
torch.Size([3, 2])
densify() turns the whole lazy stack
into a regular TensorDict. The entries with different shapes become nested
tensors. A key that only some members have is filled with an empty entry for
the members that lack it (zero rows when the members that have it agree on its
feature dims), so it appears in the result although keys() of the lazy
stack leaves it out. Pass layout=torch.jagged: the
default, torch.strided, gives a warning from PyTorch, which recommends the
jagged layout.
>>> dense = graphs.densify(layout=torch.jagged)
>>> type(dense).__name__, dense["nodes"].is_nested
('TensorDict', True)
A TensorDict can also hold a nested tensor with the torch.jagged layout
directly. Its first dimension is a batch dimension:
>>> nt = torch.nested.nested_tensor([torch.ones(2, 2), torch.ones(3, 2)], layout=torch.jagged)
>>> data = TensorDict({"nodes": nt}, batch_size=[2])
>>> data[1]["nodes"].shape
torch.Size([3, 2])
Indexing (with an int, a slice, a mask or a tensor of indices), split,
chunk and torch.cat() work along that dimension. On the nested tensor
itself, torch does only the int index and chunk along it, and raises for
the others. Leaves that share their offsets, such as token ids and their
attention mask, still share them in the result. torch.stack() raises,
because the result would need a second ragged dimension: use
lazy_stack() instead.
>>> data = torch.cat([data, data[torch.tensor([1])]])
>>> data.batch_size, [t.shape for t in data[1:]["nodes"].unbind()]
(torch.Size([3]), [torch.Size([3, 2]), torch.Size([3, 2])])
Padding with pad_sequence#
pad_sequence() pads the entries of a list of tensordicts to
the longest one along pad_dim, and stacks them. With return_mask=True,
it adds a "masks" entry that marks the values that are not padding. Use it
to get one dense TensorDict, for example for a model that takes a padded
batch. Here, three recordings of a 2-channel sensor have 2, 3, and 1 samples:
>>> from tensordict import pad_sequence
>>> tds = [TensorDict({"signal": torch.ones(n, 2)}) for n in [2, 3, 1]]
>>> padded = pad_sequence(tds, return_mask=True)
>>> padded["signal"].shape
torch.Size([3, 3, 2])
>>> padded["masks", "signal"]
tensor([[ True, True, False],
[ True, True, True],
[ True, False, False]])
Pass padding_side="left" to put the padding before the data instead.
unpad_sequence() goes back: it reads the masks and returns
a lazy stack of the samples, each with its own length:
>>> from tensordict import unpad_sequence
>>> samples = unpad_sequence(padded)
>>> [sample["signal"].shape for sample in samples.tensordicts]
[torch.Size([2, 2]), torch.Size([3, 2]), torch.Size([1, 2])]
pad_sequence raises a RuntimeError for 0-dim entries (“Cannot pad
scalars”). Give such entries one dimension, or stack them separately.
Strings and other Python objects#
A list of strings, one for each element of the batch, becomes a
NonTensorStack. The strings can have any length:
>>> TensorDict({"text": ["a", "bb", "ccc"]}, batch_size=[3])[1]["text"]
'bb'
Non-tensor data and unbatched entries explains non-tensor data and what reads return.
Choose an option#
To keep each element as it is, and to write into the elements, use a lazy stack.
To give a model one tensor with a mask, use
pad_sequence, orget(key, as_padded_tensor=True)on a lazy stack.To give a model a nested tensor, use
get(key, as_nested_tensor=True)ordensify(layout=torch.jagged).To give a variable-length attention kernel a batch without padding, pack the sequences, as in Pack variable-length sequences.
For strings and other Python objects, use a
NonTensorStack.
Lazy pre-allocation#
To fill a tensordict one element at a time, create an empty tensordict with
the full batch size, and assign each element to an index. The first assignment
creates each entry, filled with zeros, with the batch size of the tensordict.
The next assignments write into these entries in place. In this example, a
simulation writes one state for each of its N steps:
>>> N = 4
>>> def simulate(i):
... return TensorDict({"pos": torch.full((3,), float(i)), "info": {"step": torch.tensor(i)}})
>>> data = TensorDict(batch_size=[N])
>>> for i in range(N):
... data[i] = simulate(i)
>>> data["pos"]
tensor([[0., 0., 0.],
[1., 1., 1.],
[2., 2., 2.],
[3., 3., 3.]])
>>> data["info", "step"]
tensor([0, 1, 2, 3])
A later assignment can add a new key: the other elements of that entry are
zeros. A value with another shape raises a RuntimeError. The
Filling a TensorDict row by row tutorial steps through this loop.
See also#
Batch samples of different sizes without padding: a how-to for batches of molecules, point clouds or sequences, from the
DataLoaderto the model.LazyStackedTensorDict,stack(),lazy_stack(),maybe_dense_stack(),cat(), andpad_sequence()in the API reference.The tutorials Changing the shape of a TensorDict (stacking and concatenating) and Building tensordicts from streams (building lazy stacks from a stream of data).