Skip to content

Commit 6fc2be9

Browse files
committed
fix: make Zuko transform_to_unconstrained picklable (#1893)
Same _inv-nulling issue as the MDN case, but the transform is wrapped in a CallableTransform inside the flow and is also referenced by ZukoFlow._prior_transform. Give CallableTransform __getstate__/__setstate__ that store the forward transform (pickles cleanly, keeps device/dtype), and have ZukoFlow drop its redundant reference on pickling and re-link it to the flow's CallableTransform on load, so both stay the same object and device moves after a round-trip still work.
1 parent d14086c commit 6fc2be9

3 files changed

Lines changed: 56 additions & 1 deletion

File tree

sbi/neural_nets/estimators/zuko_flow.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
UnconditionalDensityEstimator,
1313
)
1414
from sbi.sbi_types import Shape, TorchTransform
15-
from sbi.utils.sbiutils import _apply_to_transform
15+
from sbi.utils.sbiutils import CallableTransform, _apply_to_transform
1616

1717

1818
class ZukoFlow(ConditionalDensityEstimator):
@@ -57,6 +57,28 @@ def _apply(self, fn):
5757
_apply_to_transform(self._prior_transform, fn)
5858
return self
5959

60+
def __getstate__(self):
61+
# `_prior_transform` is the same inverse-transform object the flow holds
62+
# (inside a CallableTransform). Torch would drop its data on pickling, so
63+
# don't pickle this redundant reference — the CallableTransform pickles the
64+
# transform correctly, and we re-link to it on load (see __setstate__).
65+
state = dict(super().__getstate__())
66+
if self._prior_transform is not None:
67+
state["_prior_transform"] = None
68+
return state
69+
70+
def __setstate__(self, state):
71+
super().__setstate__(state)
72+
self._prior_transform = self._find_prior_transform()
73+
74+
def _find_prior_transform(self) -> Optional[TorchTransform]:
75+
"""Recover the prior transform from the flow's CallableTransform, if any."""
76+
for module in self.net.modules():
77+
wrapped = getattr(module, "f", None)
78+
if isinstance(wrapped, CallableTransform):
79+
return wrapped.transform
80+
return None
81+
6082
@property
6183
def embedding_net(self) -> nn.Module:
6284
r"""Return the embedding network."""

sbi/utils/sbiutils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,16 @@ def __init__(self, transform):
295295
def __call__(self):
296296
return self.transform
297297

298+
def __getstate__(self):
299+
# `transform` is an inverse transform whose data lives behind its `_inv`
300+
# link, which torch's Transform.__getstate__ nulls on pickling. Store the
301+
# forward instead (it pickles cleanly, keeping device/dtype) and re-invert
302+
# on load, so a pickled Zuko flow keeps a working transform.
303+
return {"transform": self.transform.inv}
304+
305+
def __setstate__(self, state):
306+
self.transform = state["transform"].inv
307+
298308

299309
def biject_transform_zuko(
300310
transform: TorchTransform,

tests/save_and_load_test.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,26 @@ def test_mdn_transform_to_unconstrained_picklable():
9292
actual = reloaded.log_prob(theta, cond)
9393

9494
assert torch.allclose(expected, actual)
95+
96+
97+
def test_zuko_transform_to_unconstrained_picklable():
98+
"""A Zuko flow using transform_to_unconstrained survives a pickle round-trip.
99+
100+
Same root cause as the MDN case, but the transform is wrapped in a
101+
CallableTransform inside the flow, and is additionally referenced by
102+
ZukoFlow._prior_transform — both must come back consistent.
103+
"""
104+
from sbi.neural_nets.net_builders.flow import build_zuko_maf
105+
from sbi.utils import BoxUniform
106+
107+
prior = BoxUniform(-2 * torch.ones(2), 2 * torch.ones(2))
108+
bx, by = prior.sample((256,)), torch.randn(256, 3)
109+
est = build_zuko_maf(bx, by, z_score_x="transform_to_unconstrained", x_dist=prior)
110+
111+
theta, cond = prior.sample((5,)).unsqueeze(1), torch.randn(1, 3)
112+
expected = est.log_prob(theta, cond)
113+
114+
reloaded = pickle.loads(pickle.dumps(est))
115+
actual = reloaded.log_prob(theta, cond)
116+
117+
assert torch.allclose(expected, actual)

0 commit comments

Comments
 (0)