Skip to content

Commit a227f03

Browse files
committed
feat(inference): add mean-field variational inference scaffold
1 parent 5f89cf6 commit a227f03

4 files changed

Lines changed: 436 additions & 2 deletions

File tree

docs/development/gradient_production_pr_plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ proof-of-concept notebooks to production full-IFU workflows.
3434
5. `test(gradient-vs-fd)` (completed)
3535
- Add finite-difference validation suite for gradient correctness.
3636

37-
6. `perf(full-ifu-scaling)` (current)
37+
6. `perf(full-ifu-scaling)` (completed)
3838
- Add chunking/checkpointing controls and consistency tests.
3939

40-
7. `feat(variational-inference)`
40+
7. `feat(variational-inference)` (current)
4141
- Add first VI scaffold (mean-field baseline) on top of inference API.
4242

4343
8. `docs(examples-and-guides)`

rubix/inference/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
inverse_transforms,
1414
)
1515
from .validation import GradientComparison, compare_gradients, finite_difference_grad
16+
from .variational import (
17+
VariationalResult,
18+
initialize_mean_field_params,
19+
kl_diag_gaussian_to_standard_normal,
20+
optimize_variational_posterior,
21+
sample_diag_gaussian,
22+
)
1623

1724
__all__ = [
1825
"IdentityTransform",
@@ -21,6 +28,7 @@
2128
"SoftplusLowerBound",
2229
"GradientComparison",
2330
"OptimizationResult",
31+
"VariationalResult",
2432
"apply_params",
2533
"apply_transforms",
2634
"build_age_metallicity_transforms",
@@ -31,6 +39,10 @@
3139
"inverse_transforms",
3240
"loss",
3341
"make_inference_pipeline",
42+
"initialize_mean_field_params",
43+
"kl_diag_gaussian_to_standard_normal",
44+
"optimize_variational_posterior",
3445
"optimize_params",
46+
"sample_diag_gaussian",
3547
"value_and_grad",
3648
]

rubix/inference/variational.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
from dataclasses import dataclass
2+
from typing import Mapping, Optional
3+
4+
import jax
5+
import jax.numpy as jnp
6+
import optax
7+
from beartype.typing import Any
8+
9+
from .api import LossFn, loss
10+
from .parameterization import TransformTree, apply_transforms
11+
12+
ParamsTree = Mapping[str, Mapping[str, Any]]
13+
14+
15+
@dataclass
16+
class VariationalResult:
17+
"""Container for mean-field variational optimization outputs."""
18+
19+
posterior_mean_params: dict[str, dict[str, Any]]
20+
posterior_log_std_params: dict[str, dict[str, Any]]
21+
best_posterior_mean_params: dict[str, dict[str, Any]]
22+
objective_history: list[float]
23+
reconstruction_history: list[float]
24+
kl_history: list[float]
25+
best_objective: float
26+
steps_run: int
27+
converged: bool
28+
29+
30+
def _tree_to_dict(tree: ParamsTree) -> dict[str, dict[str, Any]]:
31+
"""Return a mutable dictionary copy from a nested parameter tree."""
32+
return {component: dict(fields) for component, fields in tree.items()}
33+
34+
35+
def initialize_mean_field_params(
36+
params_init: ParamsTree,
37+
init_log_std: float = -2.0,
38+
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
39+
"""Initialize diagonal Gaussian variational parameters.
40+
41+
Args:
42+
params_init (ParamsTree): Initial point for posterior means.
43+
init_log_std (float, optional): Initial log standard deviation for all
44+
leaves. Defaults to -2.0.
45+
46+
Returns:
47+
tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
48+
Posterior mean and posterior log-std pytrees.
49+
"""
50+
mean = _tree_to_dict(params_init)
51+
log_std = jax.tree_util.tree_map(
52+
lambda x: jnp.zeros_like(x) + init_log_std, # noqa: B023
53+
mean,
54+
)
55+
return mean, _tree_to_dict(log_std)
56+
57+
58+
def sample_diag_gaussian(
59+
mean: ParamsTree,
60+
log_std: ParamsTree,
61+
key: jnp.ndarray,
62+
) -> dict[str, dict[str, Any]]:
63+
"""Sample a pytree from a diagonal Gaussian posterior."""
64+
# Reconstruct a key tree matching ``mean`` leaves.
65+
treedef = jax.tree_util.tree_structure(mean)
66+
key_tree = jax.tree_util.tree_unflatten(
67+
treedef,
68+
list(jax.random.split(key, treedef.num_leaves)),
69+
)
70+
eps = jax.tree_util.tree_map(
71+
lambda x, k: jax.random.normal(k, shape=x.shape, dtype=x.dtype),
72+
mean,
73+
key_tree,
74+
)
75+
sample = jax.tree_util.tree_map(
76+
lambda m, ls, e: m + jnp.exp(ls) * e, # noqa: B023
77+
mean,
78+
log_std,
79+
eps,
80+
)
81+
return _tree_to_dict(sample)
82+
83+
84+
def kl_diag_gaussian_to_standard_normal(
85+
mean: ParamsTree,
86+
log_std: ParamsTree,
87+
) -> jnp.ndarray:
88+
"""Compute KL[q||p] for diagonal q vs standard normal prior p."""
89+
mean_flat, _ = jax.flatten_util.ravel_pytree(mean)
90+
log_std_flat, _ = jax.flatten_util.ravel_pytree(log_std)
91+
var_flat = jnp.exp(2.0 * log_std_flat)
92+
kl = 0.5 * jnp.sum(var_flat + mean_flat**2 - 1.0 - 2.0 * log_std_flat)
93+
return kl
94+
95+
96+
def optimize_variational_posterior(
97+
pipeline: Any,
98+
params_init: ParamsTree,
99+
static_data: Any,
100+
target: jnp.ndarray,
101+
learning_rate: float = 5e-3,
102+
max_steps: int = 500,
103+
tol: float = 1e-6,
104+
num_samples: int = 4,
105+
beta_kl: float = 1e-3,
106+
init_log_std: float = -2.0,
107+
loss_fn: Optional[LossFn] = None,
108+
noise_key: Optional[jnp.ndarray] = None,
109+
transforms: Optional[TransformTree] = None,
110+
optimizer: Optional[optax.GradientTransformation] = None,
111+
seed: int = 0,
112+
) -> VariationalResult:
113+
"""Optimize a mean-field variational posterior with reparameterization.
114+
115+
Args:
116+
pipeline (Any): Pipeline-like object consumed by :func:`rubix.inference.loss`.
117+
params_init (ParamsTree): Initial constrained parameter point.
118+
static_data (Any): Baseline RubixData passed to the forward model.
119+
target (jnp.ndarray): Target datacube or statistic.
120+
learning_rate (float, optional): Step size for default Adam optimizer.
121+
Defaults to 5e-3.
122+
max_steps (int, optional): Maximum optimization steps. Defaults to 500.
123+
tol (float, optional): Convergence threshold on update norm.
124+
Defaults to 1e-6.
125+
num_samples (int, optional): Monte Carlo samples per step. Defaults to 4.
126+
beta_kl (float, optional): KL weight. Defaults to 1e-3.
127+
init_log_std (float, optional): Initial posterior log-std. Defaults to -2.0.
128+
loss_fn (Optional[LossFn], optional): Optional custom reconstruction loss.
129+
Defaults to ``None`` (sum-of-squares).
130+
noise_key (Optional[jnp.ndarray], optional): Optional key for stochastic
131+
pipelines. Defaults to ``None``.
132+
transforms (Optional[TransformTree], optional): Optional transform tree
133+
to map unconstrained latent variables to constrained parameters.
134+
Defaults to ``None``.
135+
optimizer (Optional[optax.GradientTransformation], optional): Custom
136+
optimizer. Defaults to ``None`` (Adam).
137+
seed (int, optional): Random seed for VI sampling. Defaults to 0.
138+
139+
Raises:
140+
ValueError: If ``num_samples`` is not strictly positive.
141+
142+
Returns:
143+
VariationalResult: Posterior statistics and optimization traces.
144+
"""
145+
if num_samples <= 0:
146+
raise ValueError("num_samples must be strictly positive")
147+
148+
if optimizer is None:
149+
optimizer = optax.adam(learning_rate)
150+
151+
if transforms is None:
152+
unconstrained_init = _tree_to_dict(params_init)
153+
else:
154+
unconstrained_init = apply_transforms(
155+
params=params_init,
156+
transforms=transforms,
157+
direction="inverse",
158+
)
159+
160+
mean, log_std = initialize_mean_field_params(
161+
params_init=unconstrained_init,
162+
init_log_std=init_log_std,
163+
)
164+
variational_params = {"mean": mean, "log_std": log_std}
165+
opt_state = optimizer.init(variational_params)
166+
167+
objective_history: list[float] = []
168+
reconstruction_history: list[float] = []
169+
kl_history: list[float] = []
170+
171+
best_objective = jnp.inf
172+
best_mean = mean
173+
converged = False
174+
steps_run = 0
175+
key = jax.random.PRNGKey(seed)
176+
177+
def objective_fn(current_params, step_key):
178+
current_mean = current_params["mean"]
179+
current_log_std = current_params["log_std"]
180+
sample_keys = jax.random.split(step_key, num_samples)
181+
182+
def sample_reconstruction(sample_key):
183+
sampled_unconstrained = sample_diag_gaussian(
184+
current_mean, current_log_std, sample_key
185+
)
186+
if transforms is None:
187+
sampled_constrained = sampled_unconstrained
188+
else:
189+
sampled_constrained = apply_transforms(
190+
params=sampled_unconstrained,
191+
transforms=transforms,
192+
direction="forward",
193+
)
194+
return loss(
195+
pipeline=pipeline,
196+
params=sampled_constrained,
197+
static_data=static_data,
198+
target=target,
199+
loss_fn=loss_fn,
200+
noise_key=noise_key,
201+
)
202+
203+
reconstructions = jax.vmap(sample_reconstruction)(sample_keys)
204+
reconstruction = jnp.mean(reconstructions)
205+
kl = kl_diag_gaussian_to_standard_normal(current_mean, current_log_std)
206+
objective = reconstruction + beta_kl * kl
207+
return objective, (reconstruction, kl)
208+
209+
def objective_only(current_params, step_key):
210+
value, _ = objective_fn(current_params, step_key)
211+
return value
212+
213+
for step in range(max_steps):
214+
key, step_key = jax.random.split(key)
215+
value, grads = jax.value_and_grad(objective_only)(variational_params, step_key)
216+
updates, opt_state = optimizer.update(grads, opt_state, variational_params)
217+
variational_params = optax.apply_updates(variational_params, updates)
218+
219+
_, (reconstruction_value, kl_value) = objective_fn(variational_params, step_key)
220+
objective_history.append(float(value))
221+
reconstruction_history.append(float(reconstruction_value))
222+
kl_history.append(float(kl_value))
223+
224+
if value < best_objective:
225+
best_objective = value
226+
best_mean = variational_params["mean"]
227+
228+
steps_run = step + 1
229+
if float(optax.global_norm(updates)) < tol:
230+
converged = True
231+
break
232+
233+
final_mean = variational_params["mean"]
234+
final_log_std = variational_params["log_std"]
235+
236+
if transforms is None:
237+
posterior_mean_constrained = final_mean
238+
best_posterior_mean_constrained = best_mean
239+
else:
240+
posterior_mean_constrained = apply_transforms(
241+
params=final_mean,
242+
transforms=transforms,
243+
direction="forward",
244+
)
245+
best_posterior_mean_constrained = apply_transforms(
246+
params=best_mean,
247+
transforms=transforms,
248+
direction="forward",
249+
)
250+
251+
return VariationalResult(
252+
posterior_mean_params=_tree_to_dict(posterior_mean_constrained),
253+
posterior_log_std_params=_tree_to_dict(final_log_std),
254+
best_posterior_mean_params=_tree_to_dict(best_posterior_mean_constrained),
255+
objective_history=objective_history,
256+
reconstruction_history=reconstruction_history,
257+
kl_history=kl_history,
258+
best_objective=float(best_objective),
259+
steps_run=steps_run,
260+
converged=converged,
261+
)

0 commit comments

Comments
 (0)