Skip to content

Commit 54a5107

Browse files
peterdsharpevmoens
andauthored
[BugFix] Convert Mapping values for TensorDict fields in tensor-only tensorclasses (#1753)
Co-authored-by: Vincent Moens <vincentmoens@gmail.com>
1 parent c0c4859 commit 54a5107

2 files changed

Lines changed: 215 additions & 5 deletions

File tree

tensordict/tensorclass.py

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
import shutil
2020
import sys
2121
import warnings
22+
from collections.abc import Mapping
2223
from copy import copy, deepcopy
2324
from dataclasses import dataclass
2425
from pathlib import Path
2526
from textwrap import indent
2627
from typing import (
28+
AbstractSet,
2729
Any,
2830
Callable,
2931
get_args,
@@ -762,19 +764,30 @@ class is created. Without effect if :attr:`obj` is a type.
762764
"""
763765
from dataclasses import asdict, make_dataclass
764766

767+
def _field_specs(source, type_hints):
768+
return [
769+
(
770+
field.name,
771+
type_hints.get(field.name, field.type),
772+
copy(field),
773+
)
774+
for field in source.__dataclass_fields__.values()
775+
]
776+
765777
if isinstance(obj, type):
766778
if is_tensorclass(obj):
767779
return obj
780+
type_hints = get_type_hints(obj)
768781
if not inplace:
769782
cls = make_dataclass(
770783
obj.__name__ + "_tc",
771-
fields=obj.__dataclass_fields__,
784+
fields=_field_specs(obj, type_hints),
772785
bases=obj.__bases__,
773786
)
774787
else:
775788
cls = obj
776789
clz = _tensorclass(cls, frozen=frozen, shadow=shadow, tensor_only=tensor_only)
777-
clz._type_hints = get_type_hints(obj)
790+
_set_tensorclass_type_hints(clz, type_hints)
778791
clz._autocast = autocast
779792
clz._nocast = nocast
780793
clz._shadow = shadow
@@ -790,20 +803,37 @@ class is created. Without effect if :attr:`obj` is a type.
790803
raise TypeError(
791804
"tensor_only and autocast or nocast are exclusive features."
792805
)
806+
try:
807+
type_hints = get_type_hints(type(obj))
808+
except (NameError, TypeError):
809+
# Preserve the existing fallback for unresolved annotations.
810+
type_hints = None
793811
clz = _tensorclass(
794-
make_dataclass(name, fields=obj.__dataclass_fields__),
812+
make_dataclass(
813+
name,
814+
fields=_field_specs(type(obj), type_hints or {}),
815+
),
795816
frozen=frozen,
796817
shadow=shadow,
797818
tensor_only=tensor_only,
798819
)
820+
if type_hints is not None:
821+
_set_tensorclass_type_hints(clz, type_hints)
799822
clz._autocast = autocast
800823
clz._nocast = nocast
801824
clz._shadow = shadow
802825
clz._frozen = frozen
803826
clz._tensor_only = tensor_only
804827
else:
805828
clz = dest_cls
806-
result = clz(**asdict(obj), batch_size=batch_size, device=device)
829+
data = asdict(obj)
830+
if clz._tensor_only:
831+
# ``asdict`` recursively deep-copies values, which would discard the
832+
# identity and lock state of TensorDicts. Keep TensorDict-annotated
833+
# fields intact and let the tensor-only constructor normalize mappings.
834+
for key in clz._tensordict_fields:
835+
data[key] = getattr(obj, key)
836+
result = clz(**data, batch_size=batch_size, device=device)
807837
if auto_batch_size:
808838
if batch_size is not None:
809839
raise TypeError(
@@ -1442,10 +1472,15 @@ def wrapper(
14421472
_td_dict = _td._tensordict
14431473
_non_td = self._non_tensordict
14441474
_validate = _td._validate_value
1475+
_tensordict_fields = type(self)._tensordict_fields
14451476
for key, value in kwargs.items():
14461477
if value is None:
14471478
_non_td[key] = None
14481479
else:
1480+
if _tensordict_fields:
1481+
value, _ = _convert_mapping_for_field(
1482+
key, value, _tensordict_fields
1483+
)
14491484
_td_dict[key] = _validate(
14501485
value, check_shape=True, non_blocking=False
14511486
)
@@ -1552,12 +1587,14 @@ def get_parent_locals(cls, localns=localns):
15521587

15531588
globalns = None
15541589

1590+
cls._tensordict_fields = frozenset()
15551591
try:
1556-
cls._type_hints = get_type_hints(
1592+
type_hints = get_type_hints(
15571593
cls,
15581594
localns=localns,
15591595
# globalns=globals(),
15601596
)
1597+
_set_tensorclass_type_hints(cls, type_hints)
15611598
if tensor_only:
15621599

15631600
def is_tensor_or_optional_tensor(type_hint):
@@ -1632,6 +1669,52 @@ def is_tensor_or_optional_tensor(type_hint):
16321669
cls._type_hints = None
16331670

16341671

1672+
def _is_tensordict_annotation(type_hint: Any) -> bool:
1673+
"""Return whether an annotation contains a TensorDictBase type."""
1674+
origin = get_origin(type_hint)
1675+
if origin in (Union, UnionType):
1676+
return any(_is_tensordict_annotation(arg) for arg in get_args(type_hint))
1677+
if origin is not None:
1678+
type_hint = origin
1679+
return isinstance(type_hint, type) and issubclass(type_hint, TensorDictBase)
1680+
1681+
1682+
def _set_tensorclass_type_hints(cls: type, type_hints: dict[str, Any]) -> None:
1683+
"""Store resolved hints and cache fields with TensorDict-like annotations."""
1684+
cls._tensordict_fields = frozenset(
1685+
key
1686+
for key, val in type_hints.items()
1687+
if key in cls.__expected_keys__ and _is_tensordict_annotation(val)
1688+
)
1689+
cls._type_hints = type_hints
1690+
1691+
1692+
def _normalize_nested_mapping(
1693+
mapping: Mapping[NestedKey, Any],
1694+
) -> dict[NestedKey, Any] | TensorDictBase:
1695+
"""Materialize nested mappings while preserving TensorDictBase values."""
1696+
if isinstance(mapping, TensorDictBase):
1697+
return mapping
1698+
return {
1699+
key: _normalize_nested_mapping(value) if isinstance(value, Mapping) else value
1700+
for key, value in mapping.items()
1701+
}
1702+
1703+
1704+
def _convert_mapping_for_field(
1705+
key: str, value: Any, tensordict_fields: AbstractSet[str]
1706+
) -> tuple[Any, bool]:
1707+
"""Normalize a Mapping assigned to a TensorDict-annotated field.
1708+
1709+
Returns the normalized value and whether the field/value pair required
1710+
Mapping handling. Existing TensorDict values are returned unchanged.
1711+
"""
1712+
is_mapping_field = key in tensordict_fields and isinstance(value, Mapping)
1713+
if is_mapping_field:
1714+
value = _normalize_nested_mapping(value)
1715+
return value, is_mapping_field
1716+
1717+
16351718
def _from_tensordict(
16361719
cls,
16371720
tensordict: TensorDictBase,
@@ -2023,6 +2106,7 @@ def _setattr_tensor_only(self, key: str, value: Any) -> None: # noqa: D417
20232106
if value is None:
20242107
self._non_tensordict[key] = None
20252108
return
2109+
value, _ = _convert_mapping_for_field(key, value, type(self)._tensordict_fields)
20262110
out = self._set_str(key, value, inplace=False, validated=False, ignore_lock=False)
20272111
if out is not self:
20282112
raise RuntimeError(
@@ -2654,6 +2738,13 @@ def set_tensor(
26542738
def _is_castable(datatype):
26552739
return issubclass(datatype, (int, float, np.ndarray))
26562740

2741+
if cls._tensor_only:
2742+
value, is_mapping_field = _convert_mapping_for_field(
2743+
key, value, cls._tensordict_fields
2744+
)
2745+
if is_mapping_field:
2746+
return set_tensor(value=value)
2747+
26572748
if cls._autocast:
26582749
type_hints = cls._type_hints
26592750
if type_hints is not None:

test/tensorclass/test_tensorclass.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import re
1717
import sys
1818
import weakref
19+
from collections import UserDict
1920
from dataclasses import field
2021
from multiprocessing import Pool
2122
from pathlib import Path
@@ -35,6 +36,7 @@
3536
LazyStackedTensorDict,
3637
MemoryMappedTensor,
3738
MetaData,
39+
NonTensorData,
3840
set_capture_non_tensor_stack,
3941
set_list_to_stack,
4042
tensorclass,
@@ -3619,6 +3621,115 @@ def test_tensor_only_none(self):
36193621
delattr(x, "c")
36203622
assert not hasattr(x, "c")
36213623

3624+
@pytest.mark.parametrize("mapping_type", [dict, UserDict])
3625+
def test_tensor_only_tensordict_mapping(self, mapping_type):
3626+
@tensorclass(tensor_only=True)
3627+
class TensorOnlyMapping:
3628+
data: TensorDict
3629+
3630+
value = mapping_type({"tensor": torch.ones(())})
3631+
tc = TensorOnlyMapping(data=value)
3632+
3633+
assert isinstance(tc.data, TensorDict)
3634+
assert tc.data["tensor"] == 1
3635+
3636+
tc.data = mapping_type({"tensor": torch.zeros(())})
3637+
assert isinstance(tc.data, TensorDict)
3638+
assert tc.data["tensor"] == 0
3639+
3640+
tc.set("data", mapping_type({"tensor": torch.ones(())}))
3641+
assert isinstance(tc.data, TensorDict)
3642+
assert tc.data["tensor"] == 1
3643+
3644+
def test_tensor_only_tensordict_nested_mapping(self):
3645+
class TensorOnlyMapping(TensorClass["tensor_only"]):
3646+
data: TensorDict
3647+
3648+
tc = TensorOnlyMapping(
3649+
data=UserDict({"nested": UserDict({"tensor": torch.ones(())})})
3650+
)
3651+
3652+
assert isinstance(tc.data["nested"], TensorDict)
3653+
assert tc.data["nested", "tensor"] == 1
3654+
3655+
@pytest.mark.parametrize("set_method", ["constructor", "attribute", "set"])
3656+
def test_tensor_only_preserves_tensordict(self, set_method):
3657+
class TensorOnlyMapping(TensorClass["tensor_only"]):
3658+
data: TensorDict
3659+
3660+
value = TensorDict({"tensor": torch.ones(())}).lock_()
3661+
if set_method == "constructor":
3662+
tc = TensorOnlyMapping(data=value)
3663+
else:
3664+
tc = TensorOnlyMapping(data=TensorDict())
3665+
if set_method == "attribute":
3666+
tc.data = value
3667+
else:
3668+
tc.set("data", value)
3669+
3670+
assert tc.data is value
3671+
assert tc.data.is_locked
3672+
3673+
def test_tensor_only_preserves_nested_tensordict(self):
3674+
class TensorOnlyMapping(TensorClass["tensor_only"]):
3675+
data: TensorDict
3676+
3677+
value = TensorDict({"tensor": torch.ones(())}).lock_()
3678+
tc = TensorOnlyMapping(data=UserDict({"nested": value}))
3679+
3680+
assert tc.data["nested"] is value
3681+
assert tc.data["nested"].is_locked
3682+
3683+
@pytest.mark.parametrize("from_type", [False, True])
3684+
def test_tensor_only_mapping_from_dataclass(self, from_type):
3685+
@dataclasses.dataclass
3686+
class Data:
3687+
data: TensorDict
3688+
3689+
value = UserDict({"tensor": torch.ones(())})
3690+
if from_type:
3691+
TensorOnlyData = from_dataclass(Data, tensor_only=True)
3692+
tc = TensorOnlyData(data=value)
3693+
else:
3694+
tc = from_dataclass(Data(data=value), tensor_only=True)
3695+
3696+
assert isinstance(tc.data, TensorDict)
3697+
assert tc.data["tensor"] == 1
3698+
3699+
@pytest.mark.parametrize("nested", [False, True])
3700+
def test_tensor_only_from_dataclass_preserves_tensordict(self, nested):
3701+
@dataclasses.dataclass
3702+
class Data:
3703+
data: TensorDict
3704+
3705+
value = TensorDict({"tensor": torch.ones(())}).lock_()
3706+
data = UserDict({"nested": value}) if nested else value
3707+
3708+
tc = from_dataclass(Data(data=data), tensor_only=True)
3709+
result = tc.data["nested"] if nested else tc.data
3710+
3711+
assert result is value
3712+
assert result.is_locked
3713+
3714+
def test_mapping_with_any_annotation_stays_non_tensor(self):
3715+
class NonTensorMapping(TensorClass):
3716+
data: Any
3717+
3718+
value = UserDict({"metadata": "value"})
3719+
tc = NonTensorMapping(data=value)
3720+
3721+
assert tc.data is value
3722+
3723+
def test_tensor_only_non_tensor_mapping_stays_non_tensor(self):
3724+
class NonTensorMapping(TensorClass["tensor_only"]):
3725+
data: NonTensorData
3726+
3727+
value = UserDict({"metadata": "value"})
3728+
tc = NonTensorMapping(data=value)
3729+
3730+
assert isinstance(tc.data, NonTensorData)
3731+
assert tc.data.data is value
3732+
36223733
def test_tensor_only_autocast_nocast(self):
36233734
@tensorclass(tensor_only=True, autocast=False)
36243735
class TensorOnly:
@@ -3696,6 +3807,14 @@ class TensorOnlyGeneric:
36963807
b: TensorDict[str, torch.Tensor]
36973808
c: TensorDict[str, torch.Tensor] | None = None
36983809

3810+
tc = TensorOnlyGeneric(
3811+
a=torch.zeros(()),
3812+
b=UserDict({"tensor": torch.ones(())}),
3813+
c=UserDict({"tensor": torch.zeros(())}),
3814+
)
3815+
assert isinstance(tc.b, TensorDict)
3816+
assert isinstance(tc.c, TensorDict)
3817+
36993818

37003819
if __name__ == "__main__":
37013820
args, unknown = argparse.ArgumentParser().parse_known_args()

0 commit comments

Comments
 (0)