Skip to content

Commit 50dbede

Browse files
committed
Adds a state property that mean and std are guaranteed to be on.
1 parent f09f6b6 commit 50dbede

2 files changed

Lines changed: 223 additions & 0 deletions

File tree

pymc/variational/opvi.py

Lines changed: 93 additions & 0 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,25 @@ 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 in the original
129+
(constrained) space of the model.
130+
std : Dataset or None
131+
Posterior standard deviation of each latent variable in the
132+
original (constrained) space. ``None`` for particle-based
133+
methods.
134+
"""
135+
136+
mean: Dataset
137+
std: Dataset | None
138+
139+
120140
def _known_scan_ignored_inputs(terms):
121141
# TODO: remove when scan issue with grads is fixed
122142
from pymc.data import MinibatchOp
@@ -1172,6 +1192,79 @@ def std_data(self) -> Dataset:
11721192
"""Standard deviation of the latent variables as an xarray Dataset."""
11731193
return self.var_to_data(self.std)
11741194

1195+
def _untransform_tensor(self, flat_array: np.ndarray) -> Dataset:
1196+
"""Map a flat array in unconstrained space to an xarray Dataset in the original (constrained) space via the model transforms.
1197+
1198+
Parameters
1199+
----------
1200+
flat_array : np.ndarray
1201+
Flat parameter vector in unconstrained (transformed) space.
1202+
1203+
Returns
1204+
-------
1205+
Dataset
1206+
Named variables in the original constrained space.
1207+
"""
1208+
result = {}
1209+
for rv in self.group:
1210+
transform = self.model.rvs_to_transforms.get(rv)
1211+
value_var = self.model.rvs_to_values[rv]
1212+
name = value_var.name
1213+
_, slc, shape, dtype = self.ordering[name]
1214+
1215+
unconstrained = flat_array[slc].reshape(shape).astype(dtype)
1216+
1217+
if transform is not None:
1218+
constrained = transform.backward(
1219+
pt.as_tensor(unconstrained), *rv.owner.inputs
1220+
).eval()
1221+
else:
1222+
constrained = unconstrained
1223+
1224+
rv_name = rv.name
1225+
dims = self.model.named_vars_to_dims.get(rv_name, None)
1226+
if dims is not None:
1227+
coords = {d: np.array(self.model.coords[d]) for d in dims}
1228+
else:
1229+
coords = None
1230+
result[rv_name] = DataArray(constrained, coords=coords, dims=dims, name=rv_name)
1231+
1232+
return Dataset(result)
1233+
1234+
@property
1235+
def untransformed_mean_data(self) -> Dataset:
1236+
"""Mean of the latent variables in the original (untransformed) space.
1237+
1238+
Returns
1239+
-------
1240+
Dataset
1241+
xarray Dataset with variable names from the original model space.
1242+
"""
1243+
return self._untransform_tensor(self.mean.eval())
1244+
1245+
@property
1246+
def untransformed_std_data(self) -> Dataset:
1247+
"""Standard deviation of the latent variables in the original (untransformed) space.
1248+
1249+
Note that the standard deviation in the original space is computed
1250+
from the unconstrained parameters and may not directly correspond
1251+
to the standard deviation of the constrained posterior.
1252+
1253+
Returns
1254+
-------
1255+
Dataset
1256+
xarray Dataset with variable names from the original model space.
1257+
"""
1258+
return self._untransform_tensor(self.std.eval())
1259+
1260+
@property
1261+
def state(self) -> VIState:
1262+
"""Fit state with mean and std in the original (constrained) space."""
1263+
return VIState(
1264+
mean=self.untransformed_mean_data,
1265+
std=self.untransformed_std_data if self.has_logq else None,
1266+
)
1267+
11751268

11761269
group_for_params = Group.group_for_params
11771270
group_for_short_name = Group.group_for_short_name

tests/variational/test_inference.py

Lines changed: 130 additions & 0 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

@@ -503,3 +505,131 @@ def test_multiple_minibatch_variables():
503505
)
504506
mean_field = pm.fit(10_000, obj_optimizer=pm.adam(learning_rate=0.01), progressbar=False)
505507
np.testing.assert_allclose(mean_field.mean.get_value(), true_weights, rtol=1e-1)
508+
509+
510+
class TestUntransformedData:
511+
def test_untransformed_keys(self, hierarchical_model, hierarchical_model_data):
512+
"""Test that untransformed_mean_data uses original RV names."""
513+
with hierarchical_model:
514+
fitted = pm.fit(1, progressbar=False)
515+
516+
assert "sigma_log__" in fitted.mean_data
517+
ut = fitted.untransformed_mean_data
518+
assert set(ut.keys()) == {"sigma", "sigma_group_mu", "group_mu", "mu"}
519+
assert ut["group_mu"].shape == hierarchical_model_data["group_shape"]
520+
assert list(ut["group_mu"].coords.keys()) == list(
521+
hierarchical_model_data["group_coords"].keys()
522+
)
523+
524+
def test_log_transform_inversion(self):
525+
"""HalfNormal: verify backward via exp."""
526+
rng = np.random.default_rng(42)
527+
with pm.Model():
528+
sigma = pm.HalfNormal("sigma", sigma=5.0)
529+
mu = pm.Normal("mu", 0, 1)
530+
pm.Normal("y", mu, sigma, observed=rng.normal(size=3))
531+
fitted = pm.fit(100, progressbar=False, random_seed=42)
532+
533+
sigma_log_mean = fitted.mean_data["sigma_log__"].values
534+
sigma_mean = fitted.untransformed_mean_data["sigma"].values
535+
np.testing.assert_allclose(sigma_mean, np.exp(sigma_log_mean), rtol=1e-6)
536+
537+
def test_no_transform_passthrough(self, simple_model):
538+
"""Variables without transforms have identical values in both spaces."""
539+
with simple_model:
540+
fitted = pm.fit(100, progressbar=False, random_seed=42)
541+
542+
transformed_mu = fitted.mean_data["mu"].values
543+
untransformed_mu = fitted.untransformed_mean_data["mu"].values
544+
np.testing.assert_array_equal(transformed_mu, untransformed_mu)
545+
546+
def test_state_mean_field(self):
547+
"""ADVI state has family='mean_field', mean and std in constrained space."""
548+
rng = np.random.default_rng(42)
549+
with pm.Model():
550+
pm.HalfNormal("sigma", sigma=5.0)
551+
pm.Normal("mu", 0, 1)
552+
pm.Normal("y", rng.normal(size=3), observed=rng.normal(size=3))
553+
fitted = pm.fit(100, method="advi", progressbar=False, random_seed=42)
554+
555+
s = fitted.state
556+
assert set(s.mean.keys()) == {"sigma", "mu"}
557+
assert set(s.std.keys()) == {"sigma", "mu"}
558+
assert s.std is not None
559+
assert s.mean["sigma"].values > 0
560+
assert s.std["sigma"].values > 0
561+
562+
def test_state_full_rank(self):
563+
"""FullRankADVI state has mean and std."""
564+
rng = np.random.default_rng(42)
565+
with pm.Model():
566+
pm.HalfNormal("sigma", sigma=5.0)
567+
pm.Normal("mu", 0, 1)
568+
pm.Normal("y", rng.normal(size=3), observed=rng.normal(size=3))
569+
fitted = pm.fit(100, method="fullrank_advi", progressbar=False, random_seed=42)
570+
571+
s = fitted.state
572+
assert s.mean.keys() == {"sigma", "mu"}
573+
assert s.std is not None
574+
assert s.mean["sigma"].values > 0
575+
576+
def test_state_empirical_std_is_none(self):
577+
"""Empirical state has std=None."""
578+
rng = np.random.default_rng(42)
579+
with pm.Model():
580+
pm.Normal("mu", 0, 1)
581+
pm.Normal("y", rng.normal(size=10), observed=rng.normal(size=10))
582+
inference = pm.SVGD(n_particles=50, random_seed=42)
583+
fitted = inference.fit(100, progressbar=False)
584+
585+
s = fitted.state
586+
assert s.std is None
587+
assert "mu" in s.mean
588+
589+
def test_state_is_single_group_approx_attr(self):
590+
"""state is accessible from SingleGroupApproximation via __getattr__ proxy."""
591+
with pm.Model():
592+
pm.Normal("mu", 0, 1)
593+
inference = pm.ADVI(random_seed=42)
594+
fitted = inference.fit(10, progressbar=False)
595+
596+
s = fitted.state
597+
assert "mu" in s.mean
598+
599+
def test_state_in_callback(self):
600+
"""Callbacks can access state during training."""
601+
rng = np.random.default_rng(42)
602+
snapshots = []
603+
604+
def callback(approx, losses, i):
605+
s = approx.state
606+
snapshots.append(
607+
{
608+
"i": i,
609+
"mean": s.mean,
610+
"std": s.std,
611+
}
612+
)
613+
614+
with pm.Model():
615+
pm.HalfNormal("sigma", sigma=5.0)
616+
pm.Normal("mu", 0, 1)
617+
pm.Normal("y", rng.normal(size=3), observed=rng.normal(size=3))
618+
inference = pm.ADVI(random_seed=42)
619+
fitted = inference.fit(50, callbacks=[callback], progressbar=False)
620+
621+
assert len(snapshots) == 50
622+
for snap in snapshots:
623+
assert isinstance(snap["mean"], Dataset)
624+
assert set(snap["mean"].keys()) == {"sigma", "mu"}
625+
assert snap["std"] is not None
626+
assert set(snap["std"].keys()) == {"sigma", "mu"}
627+
# The last snapshot should match the final state
628+
final = fitted.state
629+
np.testing.assert_allclose(
630+
snapshots[-1]["mean"]["sigma"].values, final.mean["sigma"].values
631+
)
632+
# Parameters should have moved from their initial values
633+
first_mean = snapshots[0]["mean"]["mu"].values
634+
last_mean = snapshots[-1]["mean"]["mu"].values
635+
assert not np.allclose(first_mean, last_mean), "parameters should change during training"

0 commit comments

Comments
 (0)