Skip to content

Commit dbba6c6

Browse files
authored
Adds a state property that mean and std are guaranteed to be on. (#8319)
* Adds a state property that mean and std are guaranteed to be on. Uses the recently merged constrain_values utility from pymc.model.transform_values to map unconstrained parameters back to the original constrained space, replacing the custom _untransform_tensor helper.
1 parent df1a369 commit dbba6c6

3 files changed

Lines changed: 228 additions & 6 deletions

File tree

pymc/variational/inference.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def fit(
112112
progressbar_theme=default_progress_theme,
113113
*,
114114
backend=None,
115+
include_transformed=False,
115116
**kwargs,
116117
):
117118
"""Perform Operator Variational Inference.
@@ -180,6 +181,9 @@ def fit(
180181
self.approx.hist = self.hist
181182
self.state = state
182183

184+
for group in self.approx.groups:
185+
group.include_transformed = include_transformed
186+
183187
return self.approx
184188

185189
def _iterate_without_loss(self, s, n, step_func, progressbar, progressbar_theme, callbacks):
@@ -698,6 +702,7 @@ def fit(
698702
inf_kwargs=None,
699703
*,
700704
backend=None,
705+
include_transformed=False,
701706
**kwargs,
702707
):
703708
r"""Handy shortcut for using inference methods in functional way.
@@ -725,6 +730,10 @@ def fit(
725730
starting standard deviation for inference, only available for method 'advi'
726731
backend: str, optional
727732
Which computational backend to use. Recommended to be one of "numba", "c", and "jax".
733+
include_transformed: bool, default False
734+
If True, the :meth:`~Approximation.state` will also include the
735+
unconstrained (transformed) variables alongside the original model
736+
variables, similar to ``pm.sample(idata_kwargs={'include_transformed': True})``.
728737
729738
Other Parameters
730739
----------------
@@ -787,4 +796,4 @@ def fit(
787796
inference = method
788797
else:
789798
raise TypeError(f"method should be one of {set(_select.keys())} or Inference instance")
790-
return inference.fit(n, backend=backend, **kwargs)
799+
return inference.fit(n, backend=backend, include_transformed=include_transformed, **kwargs)

pymc/variational/opvi.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import itertools
5353
import warnings
5454

55+
from dataclasses import dataclass
5556
from typing import Any, overload
5657

5758
import numpy as np
@@ -117,6 +118,23 @@ class GroupError(VariationalInferenceError, TypeError):
117118
"""Error related to VI groups."""
118119

119120

121+
@dataclass
122+
class VIState:
123+
"""State of a fitted variational inference approximation.
124+
125+
Parameters
126+
----------
127+
mean : Dataset
128+
Posterior mean of each latent variable.
129+
std : Dataset or None
130+
Posterior standard deviation of each latent variable.
131+
``None`` for particle-based methods.
132+
"""
133+
134+
mean: Dataset
135+
std: Dataset | None
136+
137+
120138
def _known_scan_ignored_inputs(terms):
121139
# TODO: remove when scan issue with grads is fixed
122140
from pymc.data import MinibatchOp
@@ -1164,14 +1182,53 @@ def var_to_data(self, shared: pt.TensorVariable) -> Dataset:
11641182

11651183
@property
11661184
def mean_data(self) -> Dataset:
1167-
"""Mean of the latent variables as an xarray Dataset."""
1185+
"""Mean of the latent variables as an xarray Dataset.
1186+
1187+
These are the values the optimizer works with. For the
1188+
original model space, use :meth:`state` instead.
1189+
"""
11681190
return self.var_to_data(self.mean)
11691191

11701192
@property
11711193
def std_data(self) -> Dataset:
1172-
"""Standard deviation of the latent variables as an xarray Dataset."""
1194+
"""Standard deviation of the latent variables as an xarray Dataset.
1195+
1196+
These are the values the optimizer works with. For the
1197+
original model space, use :meth:`state` instead.
1198+
"""
11731199
return self.var_to_data(self.std)
11741200

1201+
@property
1202+
def state(self) -> VIState:
1203+
"""Fit state with mean and std as xarray Datasets."""
1204+
from pymc.model.transform_values import constrain_values
1205+
1206+
include_transformed = getattr(self, "include_transformed", False)
1207+
1208+
def _constrain_flat(flat_tensor: pt.TensorVariable) -> Dataset:
1209+
ds = self.var_to_data(flat_tensor)
1210+
ds = ds.expand_dims("__sample__")
1211+
result = constrain_values(
1212+
ds,
1213+
model=self.model,
1214+
sample_dims=("__sample__",),
1215+
compile_kwargs={"mode": "FAST_COMPILE"},
1216+
)
1217+
return result.squeeze("__sample__", drop=True)
1218+
1219+
mean_ds = _constrain_flat(self.mean)
1220+
std_ds = _constrain_flat(self.std) if self.has_logq else None
1221+
1222+
if include_transformed:
1223+
mean_ds = mean_ds.merge(self.mean_data, compat="override")
1224+
if std_ds is not None:
1225+
std_ds = std_ds.merge(self.std_data, compat="override")
1226+
1227+
return VIState(
1228+
mean=mean_ds,
1229+
std=std_ds,
1230+
)
1231+
11751232

11761233
group_for_params = Group.group_for_params
11771234
group_for_short_name = Group.group_for_short_name

tests/variational/test_inference.py

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import pytensor.tensor as pt
2323
import pytest
2424

25+
from xarray import Dataset
26+
2527
import pymc as pm
2628
import pymc.variational.opvi as opvi
2729

@@ -509,9 +511,163 @@ def test_sample_outside_model_context():
509511
with pm.Model() as model:
510512
mu = pm.Normal("mu", 0, 1)
511513

512-
# Fit with explicit model, then exit the model context
513514
approx = pm.fit(10, method="advi", model=model, progressbar=False)
514-
515-
# sample() should work without an active model context
516515
trace = approx.sample(50)
517516
assert trace.posterior["mu"].shape == (1, 50)
517+
518+
519+
class TestUntransformedData:
520+
def test_state_mean_field(self):
521+
"""ADVI state has family='mean_field', mean and std in constrained space."""
522+
rng = np.random.default_rng(42)
523+
with pm.Model():
524+
pm.HalfNormal("sigma", sigma=5.0)
525+
pm.Normal("mu", 0, 1)
526+
pm.Normal("y", rng.normal(size=3), observed=rng.normal(size=3))
527+
fitted = pm.fit(100, method="advi", progressbar=False, random_seed=42)
528+
529+
s = fitted.state
530+
assert set(s.mean.keys()) == {"sigma", "mu"}
531+
assert set(s.std.keys()) == {"sigma", "mu"}
532+
assert s.std is not None
533+
assert s.mean["sigma"].values > 0
534+
assert s.std["sigma"].values > 0
535+
536+
def test_state_full_rank(self):
537+
"""FullRankADVI state has mean and std."""
538+
rng = np.random.default_rng(42)
539+
with pm.Model():
540+
pm.HalfNormal("sigma", sigma=5.0)
541+
pm.Normal("mu", 0, 1)
542+
pm.Normal("y", rng.normal(size=3), observed=rng.normal(size=3))
543+
fitted = pm.fit(100, method="fullrank_advi", progressbar=False, random_seed=42)
544+
545+
s = fitted.state
546+
assert s.mean.keys() == {"sigma", "mu"}
547+
assert s.std is not None
548+
assert s.mean["sigma"].values > 0
549+
550+
def test_state_empirical_std_is_none(self):
551+
"""Empirical state has std=None."""
552+
rng = np.random.default_rng(42)
553+
with pm.Model():
554+
pm.Normal("mu", 0, 1)
555+
pm.Normal("y", rng.normal(size=10), observed=rng.normal(size=10))
556+
inference = pm.SVGD(n_particles=50, random_seed=42)
557+
fitted = inference.fit(100, progressbar=False)
558+
559+
s = fitted.state
560+
assert s.std is None
561+
assert "mu" in s.mean
562+
563+
def test_state_is_single_group_approx_attr(self):
564+
"""state is accessible from SingleGroupApproximation via __getattr__ proxy."""
565+
with pm.Model():
566+
pm.Normal("mu", 0, 1)
567+
inference = pm.ADVI(random_seed=42)
568+
fitted = inference.fit(10, progressbar=False)
569+
570+
s = fitted.state
571+
assert "mu" in s.mean
572+
573+
def test_state_in_callback(self):
574+
"""Callbacks can access state during training."""
575+
rng = np.random.default_rng(42)
576+
snapshots = []
577+
578+
def callback(approx, losses, i):
579+
s = approx.state
580+
snapshots.append(
581+
{
582+
"i": i,
583+
"mean": s.mean,
584+
"std": s.std,
585+
}
586+
)
587+
588+
with pm.Model():
589+
pm.HalfNormal("sigma", sigma=5.0)
590+
pm.Normal("mu", 0, 1)
591+
pm.Normal("y", rng.normal(size=3), observed=rng.normal(size=3))
592+
inference = pm.ADVI(random_seed=42)
593+
fitted = inference.fit(50, callbacks=[callback], progressbar=False)
594+
595+
assert len(snapshots) == 50
596+
for snap in snapshots:
597+
assert isinstance(snap["mean"], Dataset)
598+
assert set(snap["mean"].keys()) == {"sigma", "mu"}
599+
assert snap["std"] is not None
600+
assert set(snap["std"].keys()) == {"sigma", "mu"}
601+
# The last snapshot should match the final state
602+
final = fitted.state
603+
np.testing.assert_allclose(
604+
snapshots[-1]["mean"]["sigma"].values, final.mean["sigma"].values
605+
)
606+
# Parameters should have moved from their initial values
607+
first_mean = snapshots[0]["mean"]["mu"].values
608+
last_mean = snapshots[-1]["mean"]["mu"].values
609+
assert not np.allclose(first_mean, last_mean), "parameters should change during training"
610+
611+
def test_state_dirichlet(self):
612+
"""State works with Dirichlet (simplex transform changes dimensionality)."""
613+
with pm.Model():
614+
pm.Dirichlet("p", a=[1, 2, 3])
615+
fitted = pm.fit(50, method="advi", progressbar=False, random_seed=42)
616+
617+
s = fitted.state
618+
# Dirichlet with K=3 has K-1=2 unconstrained dims, 3 constrained dims
619+
assert "p" in s.mean
620+
assert s.mean["p"].values.shape == (3,)
621+
# Values should be on the simplex (sum to 1)
622+
np.testing.assert_allclose(s.mean["p"].values.sum(), 1.0, atol=1e-6)
623+
assert (s.mean["p"].values >= 0).all()
624+
assert (s.mean["p"].values <= 1).all()
625+
# std should also be in constrained space
626+
assert s.std is not None
627+
assert "p" in s.std
628+
assert s.std["p"].values.shape == (3,)
629+
630+
def test_state_include_transformed(self):
631+
"""include_transformed=True adds unconstrained variables to state."""
632+
with pm.Model():
633+
pm.HalfNormal("sigma", sigma=5.0)
634+
pm.Normal("mu", 0, 1)
635+
fitted = pm.fit(
636+
50,
637+
method="advi",
638+
progressbar=False,
639+
random_seed=42,
640+
include_transformed=True,
641+
)
642+
643+
s = fitted.state
644+
# Constrained variables always present
645+
assert "sigma" in s.mean
646+
assert "mu" in s.mean
647+
# Unconstrained variables included when include_transformed=True
648+
assert "sigma_log__" in s.mean
649+
# mu has no transform, so it appears only once
650+
assert list(s.mean.data_vars) == ["sigma", "mu", "sigma_log__"]
651+
assert s.std is not None
652+
assert "sigma_log__" in s.std
653+
654+
def test_state_include_transformed_dirichlet(self):
655+
"""include_transformed=True with Dirichlet (dimensionality-changing transform)."""
656+
with pm.Model():
657+
pm.Dirichlet("p", a=[1, 2, 3])
658+
fitted = pm.fit(
659+
50,
660+
method="advi",
661+
progressbar=False,
662+
random_seed=42,
663+
include_transformed=True,
664+
)
665+
666+
s = fitted.state
667+
# Constrained: p (shape 3, on simplex)
668+
assert "p" in s.mean
669+
assert s.mean["p"].values.shape == (3,)
670+
np.testing.assert_allclose(s.mean["p"].values.sum(), 1.0, atol=1e-6)
671+
# Unconstrained: p_simplex__ (shape 2, K-1 dims)
672+
assert "p_simplex__" in s.mean
673+
assert s.mean["p_simplex__"].values.shape == (2,)

0 commit comments

Comments
 (0)