fugue/inference/vi.rs
1//! Variational Inference (VI) with mean-field approximations and ELBO optimization.
2//!
3//! This module implements variational inference, an approximate inference method that
4//! turns posterior inference into an optimization problem. Instead of sampling from the
5//! true posterior, VI finds the best approximation within a chosen family of
6//! distributions by maximizing the Evidence Lower BOund (ELBO).
7//!
8//! ## Method Overview
9//!
10//! Variational inference works by:
11//! 1. Choosing a family of tractable distributions Q(θ; φ) parameterized by φ
12//! 2. Finding φ* that minimizes KL(Q(θ; φ) || P(θ|data)) (equivalently maximizes the ELBO)
13//! 3. Using Q(θ; φ*) as an approximation to the true posterior P(θ|data)
14//!
15//! ## Mean-Field Approximation
16//!
17//! This implementation uses mean-field variational inference, where the posterior
18//! is approximated as a product of independent distributions:
19//! Q(θ₁, θ₂, ..., θₖ) = Q₁(θ₁) × Q₂(θ₂) × ... × Qₖ(θₖ)
20//!
21//! Each variational factor's family is matched to the *support* of the corresponding
22//! model latent (see [`Support`]): real-valued latents get a Normal factor, strictly
23//! positive latents a LogNormal factor, and \[0,1\]-valued latents a Beta factor. Both
24//! the location **and** the scale of every factor are optimized (in unconstrained
25//! log-space for the scale parameters).
26//!
27//! ## Optimizer: stochastic, not deterministic
28//!
29//! The ELBO and its gradients are estimated by **Monte Carlo** sampling from the guide,
30//! so [`optimize_meanfield_vi`] is a *stochastic* optimizer, not a deterministic one.
31//! To make it well-behaved it uses:
32//!
33//! - **Common-random-numbers (CRN) central finite differences**: the `+ε` and `−ε`
34//! ELBO evaluations that estimate each gradient reuse the *same* seeded RNG draws so
35//! the Monte Carlo noise cancels in the difference (see [`elbo_gradient_fd`]).
36//! - **A Robbins–Monro decaying step size** `α_t = α₀ · (t+1)^(−decay)` with
37//! `decay ∈ (0.5, 1]` so that `Σ α_t = ∞` and `Σ α_t² < ∞`, which is required for a
38//! stochastic-gradient iterate to converge rather than random-walk around the optimum.
39//! - **ELBO-plateau convergence detection**: optimization stops early once the relative
40//! improvement of the windowed-mean ELBO falls below a configurable tolerance.
41//!
42//! Because every random draw flows from the caller-supplied RNG, runs are **reproducible
43//! for a fixed seed**.
44//!
45//! ## Advantages of VI
46//!
47//! - **Fast**: Typically faster than MCMC for large models
48//! - **Scalable**: Handles high-dimensional parameters well
49//! - **Reproducible**: Deterministic for a fixed RNG seed
50//! - **Convergence detection**: A clear scalar objective (the ELBO) to monitor and a
51//! built-in plateau stopping criterion
52//!
53//! ## Limitations
54//!
55//! - **Approximation quality**: Mean-field VI ignores posterior correlations and often
56//! underestimates posterior uncertainty
57//! - **Local optima**: Gradient-based optimization of a non-convex ELBO can get stuck
58//! - **Family restrictions**: The posterior must be well-approximated by the chosen family
59//! - **Gradient noise**: Beta factors have no location-scale reparameterization; their
60//! parameters are optimized purely via finite differences of the (noisy) ELBO
61//!
62//! # Examples
63//!
64//! ```rust
65//! use fugue::*;
66//! use rand::rngs::StdRng;
67//! use rand::SeedableRng;
68//! use std::collections::HashMap;
69//!
70//! // Simple VI example
71//! let model_fn = || {
72//! sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap())
73//! .bind(|mu| observe(addr!("y"), Normal::new(mu, 0.5).unwrap(), 2.0).map(move |_| mu))
74//! };
75//!
76//! // Create mean-field guide manually
77//! let mut guide = MeanFieldGuide {
78//! params: HashMap::new()
79//! };
80//! guide.params.insert(
81//! addr!("mu"),
82//! VariationalParam::Normal { mu: 0.0, log_sigma: 0.0 }
83//! );
84//!
85//! // Simple ELBO computation
86//! let mut rng = StdRng::seed_from_u64(42);
87//! let elbo = elbo_with_guide(&mut rng, &model_fn, &guide, 10);
88//! assert!(elbo.is_finite());
89//! ```
90use crate::core::address::Address;
91use crate::core::distribution::*;
92use crate::core::model::Model;
93use crate::runtime::handler::run;
94use crate::runtime::interpreters::{PriorHandler, ScoreGivenTrace};
95use crate::runtime::trace::{Choice, ChoiceValue, Trace};
96use rand::rngs::StdRng;
97use rand::{Rng, SeedableRng};
98use std::collections::HashMap;
99use std::fmt;
100
101/// Lower clamp on any log-scale variational parameter (`log_sigma`, `log_alpha`,
102/// `log_beta`). `exp(-20) ≈ 2e-9`, small enough for any realistic posterior while
103/// staying comfortably away from `-inf` (which would degenerate the factor).
104const LOG_SCALE_MIN: f64 = -20.0;
105/// Upper clamp on any log-scale variational parameter. `exp(20) ≈ 4.9e8`.
106const LOG_SCALE_MAX: f64 = 20.0;
107/// Clamp on Normal/LogNormal location parameters to prevent overflow while keeping the
108/// range wide enough not to clip realistic posterior means.
109const MU_ABS_MAX: f64 = 1.0e6;
110
111/// The support of a continuous model latent, used to pick a matching variational family.
112///
113/// Mean-field VI is only correct if each variational factor lives on the same support as
114/// the model latent it approximates: a Normal guide placed on a strictly-positive or
115/// unit-interval latent proposes out-of-support values whose model log-density is `-inf`,
116/// collapsing the ELBO. [`Support`] lets callers declare the intended support so guide
117/// construction can select the right family (see [`MeanFieldGuide::add_latent`]).
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum Support {
120 /// (-∞, +∞): approximated by a [`VariationalParam::Normal`] factor.
121 Real,
122 /// (0, +∞): approximated by a [`VariationalParam::LogNormal`] factor.
123 Positive,
124 /// (0, 1): approximated by a [`VariationalParam::Beta`] factor.
125 Unit,
126}
127
128/// Error returned when a guide cannot be constructed for a model latent.
129///
130/// The mean-field guide families implemented here ([`VariationalParam`]) are all
131/// *continuous*. A discrete latent (Bool / U64 / Usize / I64) has no continuous
132/// variational factor, so guide construction returns this typed error instead of
133/// silently emitting an `f64` factor (which would later panic when scored against the
134/// discrete model site) — see finding FG-17.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub enum GuideError {
137 /// A discrete latent was encountered where only continuous latents are supported.
138 UnsupportedDiscreteLatent {
139 /// Address of the offending latent.
140 addr: Address,
141 /// The `ChoiceValue` type name of the discrete latent (e.g. `"bool"`).
142 value_type: &'static str,
143 },
144}
145
146impl fmt::Display for GuideError {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 match self {
149 GuideError::UnsupportedDiscreteLatent { addr, value_type } => write!(
150 f,
151 "mean-field VI does not support the discrete latent at {} (type {}): \
152 only continuous latents (Normal/LogNormal/Beta factors) can be approximated",
153 addr, value_type
154 ),
155 }
156 }
157}
158
159impl std::error::Error for GuideError {}
160
161/// Which scalar coordinate of a [`VariationalParam`] a finite-difference step perturbs.
162///
163/// Every variational factor has exactly two free parameters; [`ParamCoord`] names them
164/// uniformly across families so the gradient machinery ([`elbo_gradient_fd`]) can address
165/// either one without matching on the family.
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub enum ParamCoord {
168 /// The location coordinate: `mu` (Normal/LogNormal) or `log_alpha` (Beta).
169 Location,
170 /// The scale coordinate: `log_sigma` (Normal/LogNormal) or `log_beta` (Beta).
171 Scale,
172}
173
174/// Variational distribution parameters for a single random variable.
175///
176/// Each random variable in the model gets its own variational distribution that
177/// approximates its marginal posterior. Scale parameters are stored in log-space
178/// (unconstrained) for numerical stability and to guarantee positivity.
179///
180/// # Variants
181///
182/// * `Normal` - Gaussian approximation with mean and log-standard-deviation
183/// * `LogNormal` - Log-normal approximation for positive variables
184/// * `Beta` - Beta approximation for variables constrained to \[0,1\]
185///
186/// # Examples
187///
188/// ```rust
189/// use fugue::*;
190/// use rand::rngs::StdRng;
191/// use rand::SeedableRng;
192///
193/// // Create variational parameters
194/// let normal_param = VariationalParam::Normal {
195/// mu: 1.5,
196/// log_sigma: -0.693 // sigma = 0.5
197/// };
198///
199/// let beta_param = VariationalParam::Beta {
200/// log_alpha: 1.099, // alpha = 3.0
201/// log_beta: 0.693, // beta = 2.0
202/// };
203///
204/// // Sample from variational distribution
205/// let mut rng = StdRng::seed_from_u64(42);
206/// let sample = normal_param.sample(&mut rng);
207/// let log_prob = normal_param.log_prob(sample);
208/// ```
209#[derive(Clone, Debug)]
210pub enum VariationalParam {
211 /// Normal/Gaussian variational distribution.
212 Normal {
213 /// Mean parameter.
214 mu: f64,
215 /// Log of standard deviation (for positivity).
216 log_sigma: f64,
217 },
218 /// Log-normal variational distribution for positive variables.
219 LogNormal {
220 /// Mean of underlying normal.
221 mu: f64,
222 /// Log of standard deviation of underlying normal.
223 log_sigma: f64,
224 },
225 /// Beta variational distribution for variables in \[0,1\].
226 Beta {
227 /// Log of first shape parameter (for positivity).
228 log_alpha: f64,
229 /// Log of second shape parameter (for positivity).
230 log_beta: f64,
231 },
232}
233
234impl VariationalParam {
235 /// Build a variational factor initialized for a latent with the given [`Support`].
236 ///
237 /// The family is chosen to match the support so that samples are always in the
238 /// model latent's support (avoiding the `-inf` ELBO of a support-mismatched guide,
239 /// finding FG-17). The scale is initialized to a moderate spread derived from
240 /// `init_value`; it will be optimized alongside the location.
241 ///
242 /// * [`Support::Real`] → `Normal { mu: init_value, .. }`
243 /// * [`Support::Positive`] → `LogNormal { mu: ln(init_value), .. }`
244 /// * [`Support::Unit`] → `Beta` with mean ≈ `init_value`
245 pub fn for_support(support: Support, init_value: f64) -> Self {
246 match support {
247 Support::Real => VariationalParam::Normal {
248 mu: init_value,
249 log_sigma: init_log_sigma(init_value),
250 },
251 Support::Positive => {
252 // Underlying-normal mean = ln(value); keep the argument strictly positive.
253 let safe = if init_value.is_finite() && init_value > 0.0 {
254 init_value
255 } else {
256 1.0
257 };
258 VariationalParam::LogNormal {
259 mu: safe.ln(),
260 // Underlying-normal sd = 0.5 (a moderate multiplicative spread).
261 log_sigma: 0.5_f64.ln(),
262 }
263 }
264 Support::Unit => {
265 // Weak Beta with mean m = init_value and concentration c = 2 ->
266 // alpha = c*m, beta = c*(1-m). Clamp m into (0,1) to stay valid.
267 let m = if init_value.is_finite() {
268 init_value.clamp(1e-3, 1.0 - 1e-3)
269 } else {
270 0.5
271 };
272 let concentration = 2.0;
273 VariationalParam::Beta {
274 log_alpha: (concentration * m).ln(),
275 log_beta: (concentration * (1.0 - m)).ln(),
276 }
277 }
278 }
279 }
280
281 /// Sample a value from this variational distribution with numerical stability.
282 ///
283 /// Generates a random sample using the current variational parameters. For the Beta
284 /// family this draws an **exact** Beta sample (finding FG-60): there is no
285 /// moment-matched-Gaussian approximation and no clamping.
286 ///
287 /// # Arguments
288 ///
289 /// * `rng` - Random number generator
290 ///
291 /// # Returns
292 ///
293 /// A sample from the variational distribution, or NaN if parameters are invalid.
294 pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
295 match self {
296 VariationalParam::Normal { mu, log_sigma } => {
297 let sigma = log_sigma.exp();
298 if !mu.is_finite() || !sigma.is_finite() || sigma <= 0.0 {
299 return f64::NAN;
300 }
301 Normal::new(*mu, sigma).unwrap().sample(rng)
302 }
303 VariationalParam::LogNormal { mu, log_sigma } => {
304 let sigma = log_sigma.exp();
305 if !mu.is_finite() || !sigma.is_finite() || sigma <= 0.0 {
306 return f64::NAN;
307 }
308 LogNormal::new(*mu, sigma).unwrap().sample(rng)
309 }
310 VariationalParam::Beta {
311 log_alpha,
312 log_beta,
313 } => {
314 let alpha = log_alpha.exp();
315 let beta = log_beta.exp();
316 if !alpha.is_finite() || !beta.is_finite() || alpha <= 0.0 || beta <= 0.0 {
317 return f64::NAN;
318 }
319 // Exact Beta sample via rand_distr (internally two Gamma draws).
320 Beta::new(alpha, beta).unwrap().sample(rng)
321 }
322 }
323 }
324
325 /// Sample a value together with auxiliary information for pathwise gradients.
326 ///
327 /// For the location-scale families ([`VariationalParam::Normal`],
328 /// [`VariationalParam::LogNormal`]) the auxiliary value is the standard-normal base
329 /// draw `z` used to reparameterize the sample (`x = μ + σ·z`), which supports the
330 /// reparameterization trick.
331 ///
332 /// The [`VariationalParam::Beta`] family has **no** location-scale reparameterization.
333 /// This method therefore samples the Beta **exactly** (finding FG-60 — the previous
334 /// implementation used a moment-matched Gaussian clamped to `[0.001, 0.999]`, which is
335 /// a different, biased distribution) and returns `f64::NAN` as the auxiliary value to
336 /// signal that no reparameterization base exists. Beta variational parameters are
337 /// optimized with finite-difference ELBO gradients (see [`elbo_gradient_fd`]), not
338 /// pathwise gradients.
339 pub fn sample_with_aux<R: Rng>(&self, rng: &mut R) -> (f64, f64) {
340 match self {
341 VariationalParam::Normal { mu, log_sigma } => {
342 let sigma = log_sigma.exp();
343 let z = standard_normal(rng);
344 let value = mu + sigma * z;
345 (value, z)
346 }
347 VariationalParam::LogNormal { mu, log_sigma } => {
348 let sigma = log_sigma.exp();
349 let z = standard_normal(rng);
350 let log_value = mu + sigma * z;
351 let value = log_value.exp();
352 (value, z)
353 }
354 VariationalParam::Beta {
355 log_alpha,
356 log_beta,
357 } => {
358 // Exact Beta sampling; no valid reparameterization base for Beta.
359 let value = self.sample(rng);
360 let _ = (log_alpha, log_beta);
361 (value, f64::NAN)
362 }
363 }
364 }
365
366 /// Compute log-probability of a value under this variational distribution.
367 ///
368 /// This is used for computing entropy terms in the ELBO and for evaluating
369 /// the quality of the variational approximation.
370 ///
371 /// # Arguments
372 ///
373 /// * `x` - Value to evaluate
374 ///
375 /// # Returns
376 ///
377 /// Log-probability density at the given value.
378 pub fn log_prob(&self, x: f64) -> f64 {
379 match self {
380 VariationalParam::Normal { mu, log_sigma } => {
381 let sigma = log_sigma.exp();
382 Normal::new(*mu, sigma).unwrap().log_prob(&x)
383 }
384 VariationalParam::LogNormal { mu, log_sigma } => {
385 let sigma = log_sigma.exp();
386 LogNormal::new(*mu, sigma).unwrap().log_prob(&x)
387 }
388 VariationalParam::Beta {
389 log_alpha,
390 log_beta,
391 } => {
392 let alpha = log_alpha.exp();
393 let beta = log_beta.exp();
394 Beta::new(alpha, beta).unwrap().log_prob(&x)
395 }
396 }
397 }
398}
399
400/// Draw a standard-normal sample via the Box–Muller transform.
401fn standard_normal<R: Rng>(rng: &mut R) -> f64 {
402 let u1: f64 = rng.gen::<f64>().max(1e-10);
403 let u2: f64 = rng.gen();
404 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
405}
406
407/// Initialize a Normal-factor `log_sigma` from a point value (finding FG-18).
408///
409/// Returns `ln(max(0.1·|value|, 0.1))`. This is always finite (never `ln(0) = -inf`) and
410/// NaN-proof at `value = 0` (where it yields `ln(0.1)`), giving a small-but-nonzero
411/// initial standard deviation proportional to the value's scale.
412fn init_log_sigma(value: f64) -> f64 {
413 let scale = if value.is_finite() { value.abs() } else { 1.0 };
414 (0.1 * scale).max(0.1).ln()
415}
416
417/// Return a copy of `param` with the given coordinate shifted by `delta`.
418fn shifted(param: &VariationalParam, coord: ParamCoord, delta: f64) -> VariationalParam {
419 match param {
420 VariationalParam::Normal { mu, log_sigma } => match coord {
421 ParamCoord::Location => VariationalParam::Normal {
422 mu: mu + delta,
423 log_sigma: *log_sigma,
424 },
425 ParamCoord::Scale => VariationalParam::Normal {
426 mu: *mu,
427 log_sigma: log_sigma + delta,
428 },
429 },
430 VariationalParam::LogNormal { mu, log_sigma } => match coord {
431 ParamCoord::Location => VariationalParam::LogNormal {
432 mu: mu + delta,
433 log_sigma: *log_sigma,
434 },
435 ParamCoord::Scale => VariationalParam::LogNormal {
436 mu: *mu,
437 log_sigma: log_sigma + delta,
438 },
439 },
440 VariationalParam::Beta {
441 log_alpha,
442 log_beta,
443 } => match coord {
444 ParamCoord::Location => VariationalParam::Beta {
445 log_alpha: log_alpha + delta,
446 log_beta: *log_beta,
447 },
448 ParamCoord::Scale => VariationalParam::Beta {
449 log_alpha: *log_alpha,
450 log_beta: log_beta + delta,
451 },
452 },
453 }
454}
455
456/// Apply an additive update to one coordinate of `param`, clamping to safe ranges.
457fn apply_update(param: &mut VariationalParam, coord: ParamCoord, delta: f64) {
458 match param {
459 VariationalParam::Normal { mu, log_sigma } => match coord {
460 ParamCoord::Location => *mu = (*mu + delta).clamp(-MU_ABS_MAX, MU_ABS_MAX),
461 ParamCoord::Scale => {
462 *log_sigma = (*log_sigma + delta).clamp(LOG_SCALE_MIN, LOG_SCALE_MAX)
463 }
464 },
465 VariationalParam::LogNormal { mu, log_sigma } => match coord {
466 ParamCoord::Location => *mu = (*mu + delta).clamp(-MU_ABS_MAX, MU_ABS_MAX),
467 ParamCoord::Scale => {
468 *log_sigma = (*log_sigma + delta).clamp(LOG_SCALE_MIN, LOG_SCALE_MAX)
469 }
470 },
471 VariationalParam::Beta {
472 log_alpha,
473 log_beta,
474 } => match coord {
475 ParamCoord::Location => {
476 *log_alpha = (*log_alpha + delta).clamp(LOG_SCALE_MIN, LOG_SCALE_MAX)
477 }
478 ParamCoord::Scale => {
479 *log_beta = (*log_beta + delta).clamp(LOG_SCALE_MIN, LOG_SCALE_MAX)
480 }
481 },
482 }
483}
484
485/// Mean-field variational guide for approximate posterior inference.
486///
487/// A mean-field guide specifies independent variational distributions for each
488/// random variable in the model. This factorization assumption simplifies
489/// optimization but may underestimate correlations between variables.
490///
491/// The guide maps each address (random variable) to its variational parameters,
492/// which are optimized to minimize the KL divergence to the true posterior.
493///
494/// # Fields
495///
496/// * `params` - Map from addresses to their variational parameters
497///
498/// # Examples
499///
500/// ```rust
501/// use fugue::*;
502/// use std::collections::HashMap;
503///
504/// // Create a guide for a two-parameter model
505/// let mut guide = MeanFieldGuide::new();
506/// guide.params.insert(
507/// addr!("mu"),
508/// VariationalParam::Normal { mu: 0.0, log_sigma: 0.0 }
509/// );
510/// guide.params.insert(
511/// addr!("sigma"),
512/// VariationalParam::Normal { mu: 0.0, log_sigma: -1.0 }
513/// );
514///
515/// // Check if parameters are specified
516/// assert!(guide.params.contains_key(&addr!("mu")));
517/// assert!(guide.params.contains_key(&addr!("sigma")));
518/// ```
519#[derive(Clone, Debug)]
520pub struct MeanFieldGuide {
521 /// Map from addresses to their variational parameters.
522 pub params: HashMap<Address, VariationalParam>,
523}
524
525impl Default for MeanFieldGuide {
526 fn default() -> Self {
527 Self::new()
528 }
529}
530
531impl MeanFieldGuide {
532 /// Create a new empty mean-field guide.
533 ///
534 /// The guide starts with no variational parameters. Add a factor for each latent in
535 /// your model with [`MeanFieldGuide::add_latent`] (support-aware) or by inserting into
536 /// [`MeanFieldGuide::params`] directly.
537 pub fn new() -> Self {
538 Self {
539 params: HashMap::new(),
540 }
541 }
542
543 /// Add a support-matched variational factor for a latent (finding FG-17).
544 ///
545 /// The variational family is selected from the declared [`Support`] so the factor's
546 /// samples always lie in the model latent's support: real → Normal, positive →
547 /// LogNormal, \[0,1\] → Beta. `init_value` seeds the factor's location.
548 ///
549 /// ```rust
550 /// use fugue::*;
551 /// use fugue::inference::vi::{MeanFieldGuide, Support};
552 ///
553 /// let mut guide = MeanFieldGuide::new();
554 /// guide.add_latent(addr!("theta"), Support::Unit, 0.3); // Beta factor
555 /// guide.add_latent(addr!("rate"), Support::Positive, 2.0); // LogNormal factor
556 /// guide.add_latent(addr!("mu"), Support::Real, 0.0); // Normal factor
557 /// assert_eq!(guide.params.len(), 3);
558 /// ```
559 pub fn add_latent(&mut self, addr: Address, support: Support, init_value: f64) {
560 self.params
561 .insert(addr, VariationalParam::for_support(support, init_value));
562 }
563
564 /// Initialize a guide from a prior trace, defaulting continuous latents to a Normal
565 /// factor on the real line.
566 ///
567 /// A [`Trace`] records only sampled *values*, not the support of the distributions
568 /// that produced them, so this constructor cannot infer positive/unit support from a
569 /// single draw (doing so from the sign of one sample was the FG-18 antipattern). It
570 /// therefore builds a real-line Normal factor for every continuous (`f64`) latent,
571 /// with a finite, value-scaled initial standard deviation (`init_log_sigma`, finding
572 /// FG-18). For support-aware factors use [`MeanFieldGuide::add_latent`].
573 ///
574 /// Discrete latents (`Bool` / `U64` / `Usize` / `I64`) have no continuous variational
575 /// factor and yield a typed [`GuideError::UnsupportedDiscreteLatent`] instead of a
576 /// silent `f64` factor that would later panic during scoring (finding FG-17).
577 pub fn from_trace(trace: &Trace) -> Result<Self, GuideError> {
578 let mut guide = Self::new();
579
580 for (addr, choice) in &trace.choices {
581 let param = match choice.value {
582 ChoiceValue::F64(val) => VariationalParam::Normal {
583 mu: val,
584 log_sigma: init_log_sigma(val),
585 },
586 // Discrete latents are unsupported by the continuous mean-field families.
587 ChoiceValue::Bool(_)
588 | ChoiceValue::I64(_)
589 | ChoiceValue::U64(_)
590 | ChoiceValue::Usize(_) => {
591 return Err(GuideError::UnsupportedDiscreteLatent {
592 addr: addr.clone(),
593 value_type: choice.value.type_name(),
594 });
595 }
596 };
597 guide.params.insert(addr.clone(), param);
598 }
599 Ok(guide)
600 }
601
602 /// Sample a trace from the guide.
603 ///
604 /// Factors are sampled in a deterministic (address-sorted) order so that, for a fixed
605 /// RNG seed, two guides with the same set of addresses consume the RNG identically —
606 /// this is what makes the common-random-numbers finite differences in
607 /// [`elbo_gradient_fd`] valid. All factor families are continuous, so values are
608 /// stored as `ChoiceValue::F64`.
609 pub fn sample_trace<R: Rng>(&self, rng: &mut R) -> Trace {
610 let mut trace = Trace::default();
611
612 let mut entries: Vec<(&Address, &VariationalParam)> = self.params.iter().collect();
613 entries.sort_by(|a, b| a.0.cmp(b.0));
614
615 for (addr, param) in entries {
616 let value = param.sample(rng);
617 let log_prob = param.log_prob(value);
618
619 trace.choices.insert(
620 addr.clone(),
621 Choice {
622 addr: addr.clone(),
623 value: ChoiceValue::F64(value),
624 logp: log_prob,
625 },
626 );
627 trace.log_prior += log_prob;
628 }
629 trace
630 }
631}
632
633/// Monte Carlo estimate of the ELBO for a model under a variational `guide`.
634///
635/// Returns the sample mean over `num_samples` draws `z ~ q` of
636/// `log p(x, z) − log q(z)`. Only the guide factors for addresses the model actually
637/// samples contribute the `− log q(z)` (entropy) term, so a stray guide factor for an
638/// address the model never visits cannot bias the estimate (finding FG-17).
639pub fn elbo_with_guide<A, R: Rng>(
640 rng: &mut R,
641 model_fn: impl Fn() -> Model<A>,
642 guide: &MeanFieldGuide,
643 num_samples: usize,
644) -> f64 {
645 let mut total_elbo = 0.0;
646
647 for _ in 0..num_samples {
648 let guide_trace = guide.sample_trace(rng);
649 let (_a, model_trace) = run(
650 ScoreGivenTrace {
651 base: guide_trace.clone(),
652 trace: Trace::default(),
653 },
654 model_fn(),
655 );
656
657 // ELBO = E_q[log p(x,z) - log q(z)].
658 let log_joint = model_trace.total_log_weight();
659 // Only count the guide entropy for latents the model actually sampled.
660 let log_guide: f64 = model_trace
661 .choices
662 .keys()
663 .filter_map(|addr| guide_trace.choices.get(addr).map(|c| c.logp))
664 .sum();
665 total_elbo += log_joint - log_guide;
666 }
667
668 total_elbo / num_samples as f64
669}
670
671/// Common-random-numbers central finite-difference estimate of `dELBO/dφ` for one
672/// coordinate of one guide factor (finding FG-16).
673///
674/// The `+ε` and `−ε` ELBO evaluations are run with **freshly seeded RNGs sharing the same
675/// `seed`**, so the guide draws `z ~ q` are identical between them and the Monte Carlo
676/// noise cancels in the difference — only the `O(ε²)` central-difference bias remains.
677/// Both evaluations use the same `num_samples`. Contrast this with a naive
678/// `(elbo(φ+ε) − elbo(φ))/ε` using independent draws, whose variance is inflated by
679/// `1/ε²` and swamps the signal.
680///
681/// # Arguments
682/// * `seed` - RNG seed shared by both perturbed evaluations (common random numbers).
683/// * `addr` - Address of the factor to differentiate; must be present in `guide`.
684/// * `coord` - Which of the factor's two coordinates to perturb.
685/// * `eps` - Finite-difference half-step (in unconstrained parameter space).
686/// * `num_samples` - Monte Carlo samples per ELBO evaluation.
687pub fn elbo_gradient_fd<A>(
688 seed: u64,
689 model_fn: impl Fn() -> Model<A>,
690 guide: &MeanFieldGuide,
691 addr: &Address,
692 coord: ParamCoord,
693 eps: f64,
694 num_samples: usize,
695) -> f64 {
696 let base = match guide.params.get(addr) {
697 Some(p) => p,
698 None => return 0.0,
699 };
700
701 let mut guide_plus = guide.clone();
702 guide_plus
703 .params
704 .insert(addr.clone(), shifted(base, coord, eps));
705 let mut guide_minus = guide.clone();
706 guide_minus
707 .params
708 .insert(addr.clone(), shifted(base, coord, -eps));
709
710 // Common random numbers: identical seed => identical z ~ q draws for + and -.
711 let elbo_plus = elbo_with_guide(
712 &mut StdRng::seed_from_u64(seed),
713 &model_fn,
714 &guide_plus,
715 num_samples,
716 );
717 let elbo_minus = elbo_with_guide(
718 &mut StdRng::seed_from_u64(seed),
719 &model_fn,
720 &guide_minus,
721 num_samples,
722 );
723
724 (elbo_plus - elbo_minus) / (2.0 * eps)
725}
726
727/// Configuration for [`optimize_meanfield_vi_with_config`].
728#[derive(Clone, Debug)]
729pub struct VIConfig {
730 /// Maximum number of optimization iterations.
731 pub n_iterations: usize,
732 /// Monte Carlo samples per ELBO / gradient evaluation.
733 pub n_samples_per_iter: usize,
734 /// Base step size `α₀`. The effective step at iteration `t` is
735 /// `α₀ · (t+1)^(−step_decay_exponent)`.
736 pub base_learning_rate: f64,
737 /// Finite-difference half-step `ε` used for the CRN central differences.
738 pub fd_eps: f64,
739 /// Relative-improvement tolerance for the ELBO-plateau convergence test.
740 pub convergence_tol: f64,
741 /// Window length (in iterations) for the ELBO-plateau convergence test.
742 pub convergence_window: usize,
743 /// Robbins–Monro step-decay exponent (must be in `(0.5, 1]` for convergence).
744 pub step_decay_exponent: f64,
745}
746
747impl Default for VIConfig {
748 fn default() -> Self {
749 Self {
750 n_iterations: 1000,
751 n_samples_per_iter: 16,
752 base_learning_rate: 0.1,
753 fd_eps: 0.01,
754 convergence_tol: 1e-4,
755 convergence_window: 20,
756 step_decay_exponent: 0.6,
757 }
758 }
759}
760
761/// Result of running [`optimize_meanfield_vi_with_config`].
762#[derive(Clone, Debug)]
763pub struct VIResult {
764 /// The optimized guide.
765 pub guide: MeanFieldGuide,
766 /// Per-iteration ELBO estimates (state at the start of each iteration).
767 pub elbo_history: Vec<f64>,
768 /// Whether the ELBO-plateau convergence criterion fired before `n_iterations`.
769 pub converged: bool,
770 /// Number of iterations actually run.
771 pub iterations: usize,
772}
773
774/// Optimize a mean-field guide by stochastic gradient ascent on the ELBO.
775///
776/// This is the configurable entry point (see [`VIConfig`]). All variational parameters —
777/// **both** location and scale, in unconstrained log-space for the scales — are updated
778/// (finding FG-04), using common-random-numbers central finite-difference gradients
779/// (finding FG-16, via [`elbo_gradient_fd`]), a Robbins–Monro decaying step size and an
780/// ELBO-plateau convergence test (finding FG-44).
781///
782/// The optimizer is stochastic but fully determined by `rng`, so a seeded RNG gives
783/// reproducible results.
784pub fn optimize_meanfield_vi_with_config<A, R: Rng>(
785 rng: &mut R,
786 model_fn: impl Fn() -> Model<A>,
787 initial_guide: MeanFieldGuide,
788 config: &VIConfig,
789) -> VIResult {
790 let mut guide = initial_guide;
791 let mut elbo_history: Vec<f64> = Vec::with_capacity(config.n_iterations);
792 let mut converged = false;
793 let mut iterations = 0;
794
795 for iter in 0..config.n_iterations {
796 iterations = iter + 1;
797
798 // Monitor the ELBO at the start of this iteration (seeded from `rng` so the run
799 // stays reproducible).
800 let monitor_seed: u64 = rng.gen();
801 let current_elbo = elbo_with_guide(
802 &mut StdRng::seed_from_u64(monitor_seed),
803 &model_fn,
804 &guide,
805 config.n_samples_per_iter,
806 );
807 elbo_history.push(current_elbo);
808
809 // ELBO-plateau convergence: compare the mean ELBO of the two most recent
810 // non-overlapping windows; stop when the relative change is below tolerance.
811 let w = config.convergence_window;
812 if w > 0 && elbo_history.len() >= 2 * w {
813 let n = elbo_history.len();
814 let recent: f64 = elbo_history[n - w..].iter().sum::<f64>() / w as f64;
815 let previous: f64 = elbo_history[n - 2 * w..n - w].iter().sum::<f64>() / w as f64;
816 let denom = previous.abs().max(1e-8);
817 if (recent - previous).abs() / denom < config.convergence_tol {
818 converged = true;
819 break;
820 }
821 }
822
823 // Robbins-Monro decaying step size.
824 let step =
825 config.base_learning_rate * ((iter + 1) as f64).powf(-config.step_decay_exponent);
826
827 // Compute all coordinate gradients from a snapshot of the guide (Jacobi update),
828 // then apply. Addresses are visited in sorted order for reproducibility.
829 let snapshot = guide.clone();
830 let mut addrs: Vec<Address> = snapshot.params.keys().cloned().collect();
831 addrs.sort();
832
833 for addr in &addrs {
834 for coord in [ParamCoord::Location, ParamCoord::Scale] {
835 // Independent seed per coordinate; identical within the +/- pair (CRN).
836 let seed: u64 = rng.gen();
837 let grad = elbo_gradient_fd(
838 seed,
839 &model_fn,
840 &snapshot,
841 addr,
842 coord,
843 config.fd_eps,
844 config.n_samples_per_iter,
845 );
846 if grad.is_finite() {
847 let update = step * grad;
848 if update.is_finite() {
849 if let Some(param) = guide.params.get_mut(addr) {
850 apply_update(param, coord, update);
851 }
852 }
853 }
854 }
855 }
856 }
857
858 VIResult {
859 guide,
860 elbo_history,
861 converged,
862 iterations,
863 }
864}
865
866/// Optimize a mean-field guide by stochastic gradient ascent on the ELBO.
867///
868/// Convenience wrapper over [`optimize_meanfield_vi_with_config`] using [`VIConfig`]
869/// defaults for the finite-difference step, convergence criterion and step-decay
870/// schedule, with the supplied iteration count, sample count and base learning rate. It
871/// optimizes **both** the location and the scale of every factor (finding FG-04). For
872/// convergence diagnostics or full configurability, call
873/// [`optimize_meanfield_vi_with_config`] directly.
874pub fn optimize_meanfield_vi<A, R: Rng>(
875 rng: &mut R,
876 model_fn: impl Fn() -> Model<A>,
877 initial_guide: MeanFieldGuide,
878 n_iterations: usize,
879 n_samples_per_iter: usize,
880 learning_rate: f64,
881) -> MeanFieldGuide {
882 let config = VIConfig {
883 n_iterations,
884 n_samples_per_iter,
885 base_learning_rate: learning_rate,
886 ..VIConfig::default()
887 };
888 optimize_meanfield_vi_with_config(rng, model_fn, initial_guide, &config).guide
889}
890
891/// Monte Carlo ELBO using the model's **prior** as the variational guide.
892///
893/// With `q = prior`, the ELBO `E_q[log p(x,z) − log q(z)]` telescopes to
894/// `E_prior[log p(x | z)]` (the prior log-density cancels), i.e. the sample mean of the
895/// per-draw log-likelihood-plus-factor contributions. By Jensen this is a valid lower
896/// bound on the log evidence `log p(x)`.
897///
898/// This is the zero-configuration ELBO: it needs no fitted guide, but the prior is
899/// usually a poor proposal so the bound is loose. For a bound against an arbitrary
900/// (optimized) guide, use [`elbo_with_guide`].
901///
902/// Note (finding FG-46): earlier versions of this function averaged the *joint*
903/// `log p(x, z)` and mislabeled it an ELBO, double-counting the prior entropy. It now
904/// correctly omits the `log p(z)` term.
905pub fn estimate_elbo<A, R: Rng>(
906 rng: &mut R,
907 model_fn: impl Fn() -> Model<A>,
908 num_samples: usize,
909) -> f64 {
910 let mut total = 0.0;
911 for _ in 0..num_samples {
912 let (_a, prior_t) = run(
913 PriorHandler {
914 rng,
915 trace: Trace::default(),
916 },
917 model_fn(),
918 );
919 // ELBO with q = prior = E_prior[log p(x|z)] = likelihood + factors only.
920 total += prior_t.log_likelihood + prior_t.log_factors;
921 }
922 total / (num_samples as f64)
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928 use crate::addr;
929
930 use crate::core::model::{observe, sample, ModelExt};
931 use crate::runtime::trace::{Choice, ChoiceValue, Trace};
932 use rand::rngs::StdRng;
933 use rand::SeedableRng;
934
935 #[test]
936 fn variational_param_sampling_and_log_prob() {
937 let mut rng = StdRng::seed_from_u64(20);
938 let vp_n = VariationalParam::Normal {
939 mu: 0.0,
940 log_sigma: 0.0,
941 };
942 let x = vp_n.sample(&mut rng);
943 assert!(x.is_finite());
944 assert!(vp_n.log_prob(x).is_finite());
945
946 let vp_b = VariationalParam::Beta {
947 log_alpha: (2.0f64).ln(),
948 log_beta: (3.0f64).ln(),
949 };
950 let y = vp_b.sample(&mut rng);
951 assert!(y > 0.0 && y < 1.0);
952 assert!(vp_b.log_prob(y).is_finite());
953 }
954
955 #[test]
956 fn elbo_computation_is_finite() {
957 let model_fn = || {
958 sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
959 observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.2).map(move |_| mu)
960 })
961 };
962
963 // Build a simple guide
964 let mut guide = MeanFieldGuide::new();
965 guide.params.insert(
966 addr!("mu"),
967 VariationalParam::Normal {
968 mu: 0.0,
969 log_sigma: 0.0,
970 },
971 );
972
973 let mut rng = StdRng::seed_from_u64(21);
974 let elbo = elbo_with_guide(&mut rng, model_fn, &guide, 5);
975 assert!(elbo.is_finite());
976 }
977
978 #[test]
979 fn meanfield_from_trace_continuous_ok() {
980 // Only continuous (f64) latents -> Ok, all Normal factors on the real line.
981 let mut base = Trace::default();
982 base.choices.insert(
983 addr!("pos"),
984 Choice {
985 addr: addr!("pos"),
986 value: ChoiceValue::F64(-1.0),
987 logp: -0.1,
988 },
989 );
990 base.choices.insert(
991 addr!("z"),
992 Choice {
993 addr: addr!("z"),
994 value: ChoiceValue::F64(0.0),
995 logp: -0.2,
996 },
997 );
998
999 let guide = MeanFieldGuide::from_trace(&base).expect("continuous trace should build");
1000 assert_eq!(guide.params.len(), 2);
1001 // FG-18: value == 0.0 must not produce log_sigma = ln(0) = -inf.
1002 if let VariationalParam::Normal { log_sigma, .. } = guide.params.get(&addr!("z")).unwrap() {
1003 assert!(log_sigma.is_finite());
1004 assert!(log_sigma.exp() > 0.0);
1005 } else {
1006 panic!("expected Normal factor");
1007 }
1008
1009 // Sampling produces a finite trace (no NaN from a degenerate sigma).
1010 let t = guide.sample_trace(&mut StdRng::seed_from_u64(22));
1011 assert!(!t.choices.is_empty());
1012 assert!(t.log_prior.is_finite());
1013 }
1014
1015 #[test]
1016 fn optimize_vi_updates_parameters_and_is_stable() {
1017 let model_fn = || {
1018 sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
1019 observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.3).map(move |_| mu)
1020 })
1021 };
1022
1023 let mut guide = MeanFieldGuide::new();
1024 guide.params.insert(
1025 addr!("mu"),
1026 VariationalParam::Normal {
1027 mu: 0.0,
1028 log_sigma: 0.0,
1029 },
1030 );
1031
1032 let optimized = optimize_meanfield_vi(
1033 &mut StdRng::seed_from_u64(23),
1034 model_fn,
1035 guide.clone(),
1036 5, // small iterations for speed
1037 4,
1038 0.1,
1039 );
1040
1041 // Parameter exists and remains finite / within clamped bounds.
1042 if let VariationalParam::Normal { mu, log_sigma } =
1043 optimized.params.get(&addr!("mu")).unwrap()
1044 {
1045 assert!(mu.is_finite() && mu.abs() <= MU_ABS_MAX);
1046 assert!(log_sigma.is_finite());
1047 } else {
1048 panic!("expected Normal param");
1049 }
1050 }
1051}