Skip to main content

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}