Skip to main content

tensorlogic_scirs_backend/probabilistic/
variational.rs

1//! Mean-field Gaussian Variational Inference.
2//!
3//! This module implements the standard "black-box" variational inference (BBVI)
4//! algorithm for fitting a mean-field Gaussian posterior q(z) = ∏ N(μ_i, σ_i²)
5//! to an unnormalised target log p(z).
6//!
7//! ## Algorithm
8//!
9//! Maximise the ELBO:
10//! ```text
11//! L(λ) = E_q[log p(z)] + H[q]
12//! ```
13//! where H\[q\] = Σ_i log σ_i + const (analytic entropy of a diagonal Gaussian).
14//!
15//! Gradients of E_q[log p(z)] are estimated via the reparameterisation trick:
16//! ```text
17//! ∂L/∂μ_i      ≈ (1/S) Σ_s ∂log p(z_s)/∂z_s^i
18//! ∂L/∂log_σ_i  ≈ (1/S) Σ_s ∂log p(z_s)/∂z_s^i · ε_s^i · σ_i  +  1
19//! ```
20//! where z_s = μ + σ ⊙ ε_s, ε_s ~ N(0, I), and gradients of log p are
21//! computed by central finite differences.  Adam updates are applied to all
22//! variational parameters.
23
24use crate::error::{TlBackendError, TlBackendResult};
25use scirs2_core::random::prelude::*;
26use scirs2_core::random::Distribution;
27
28// ============================================================================
29// MeanFieldGaussian
30// ============================================================================
31
32/// Diagonal (mean-field) Gaussian variational distribution q(z) = ∏ N(μ_i, σ_i²).
33#[derive(Debug, Clone)]
34pub struct MeanFieldGaussian {
35    /// Location parameters μ.
36    pub mu: Vec<f64>,
37    /// Log-scale parameters log σ (σ = exp(log_sigma) > 0 always).
38    pub log_sigma: Vec<f64>,
39}
40
41impl MeanFieldGaussian {
42    /// Dimensionality of the distribution.
43    pub fn dim(&self) -> usize {
44        self.mu.len()
45    }
46
47    /// Scale parameters σ = exp(log_sigma).
48    pub fn sigma(&self) -> Vec<f64> {
49        self.log_sigma.iter().map(|&ls| ls.exp()).collect()
50    }
51
52    /// Draw one sample via the reparameterisation trick: z = μ + σ ⊙ ε, ε ~ N(0, I).
53    pub fn sample(&self, rng: &mut impl Rng) -> Vec<f64> {
54        let sigma = self.sigma();
55        let normal = Normal::new(0.0_f64, 1.0).expect("N(0,1) is always valid");
56        self.mu
57            .iter()
58            .zip(sigma.iter())
59            .map(|(&m, &s)| {
60                let eps: f64 = normal.sample(rng);
61                m + s * eps
62            })
63            .collect()
64    }
65}
66
67// ============================================================================
68// VariationalConfig
69// ============================================================================
70
71/// Hyper-parameters for the variational inference algorithm.
72#[derive(Debug, Clone)]
73pub struct VariationalConfig {
74    /// Number of gradient ascent steps.
75    pub steps: usize,
76    /// Adam learning rate.
77    pub learning_rate: f64,
78    /// Number of MC samples per ELBO gradient estimate.
79    pub mc_samples: usize,
80    /// Optional seed for reproducibility.
81    pub seed: Option<u64>,
82}
83
84impl Default for VariationalConfig {
85    fn default() -> Self {
86        Self {
87            steps: 500,
88            learning_rate: 0.01,
89            mc_samples: 10,
90            seed: None,
91        }
92    }
93}
94
95// ============================================================================
96// VariationalInference
97// ============================================================================
98
99/// Entry-point for black-box mean-field variational inference.
100pub struct VariationalInference;
101
102impl VariationalInference {
103    /// Fit a mean-field Gaussian posterior q(z) to the log-joint `log_prob`.
104    ///
105    /// # Arguments
106    /// * `log_prob` — evaluates log p(z) at a given z (up to a constant)
107    /// * `dim`      — number of latent dimensions
108    /// * `config`   — algorithm hyper-parameters
109    ///
110    /// # Algorithm
111    /// Uses Adam (β₁=0.9, β₂=0.999, ε=1e-8) to maximise the ELBO.  Gradients
112    /// of the expected log-joint are estimated by the reparameterisation trick
113    /// with central finite differences (h=1e-5) for ∂log_p/∂z.
114    ///
115    /// # Errors
116    /// Returns an error if `dim == 0`.
117    pub fn fit(
118        log_prob: impl Fn(&[f64]) -> f64,
119        dim: usize,
120        config: VariationalConfig,
121    ) -> TlBackendResult<MeanFieldGaussian> {
122        if dim == 0 {
123            return Err(TlBackendError::InvalidOperation(
124                "VariationalInference::fit: dim must be > 0".to_string(),
125            ));
126        }
127
128        if let Some(s) = config.seed {
129            let mut rng = seeded_rng(s);
130            fit_inner(log_prob, dim, &config, &mut rng)
131        } else {
132            let mut rng = thread_rng();
133            fit_inner(log_prob, dim, &config, &mut rng)
134        }
135    }
136}
137
138// ============================================================================
139// Inner optimisation loop (concrete RNG type to allow Distribution::sample)
140// ============================================================================
141
142/// Core optimisation loop parametric over a concrete RNG type.
143///
144/// The seeded/unseeded branches in `VariationalInference::fit` delegate here
145/// with their respective concrete `Random<StdRng>` / `Random<ThreadRng>` types,
146/// following the same dual-branch pattern as `gradient_ops::sample_gumbel`.
147fn fit_inner<R: Rng>(
148    log_prob: impl Fn(&[f64]) -> f64,
149    dim: usize,
150    config: &VariationalConfig,
151    rng: &mut Random<R>,
152) -> TlBackendResult<MeanFieldGaussian> {
153    // ---- Initialisation ------------------------------------------------
154    // μ = 0, log_σ = 0 (σ = 1)
155    let mut mu = vec![0.0_f64; dim];
156    let mut log_sigma = vec![0.0_f64; dim];
157
158    // Adam moment buffers for μ and log_σ
159    let mut m_mu = vec![0.0_f64; dim];
160    let mut v_mu = vec![0.0_f64; dim];
161    let mut m_ls = vec![0.0_f64; dim];
162    let mut v_ls = vec![0.0_f64; dim];
163
164    let beta1 = 0.9_f64;
165    let beta2 = 0.999_f64;
166    let adam_eps = 1e-8_f64;
167    let fd_h = 1e-5_f64;
168
169    let normal_dist = Normal::new(0.0_f64, 1.0).expect("N(0,1) is always valid");
170
171    // ---- Main loop -----------------------------------------------------
172    for step in 0..config.steps {
173        let adam_t = step + 1;
174
175        // Gradient accumulators
176        let mut grad_mu = vec![0.0_f64; dim];
177        let mut grad_ls = vec![0.0_f64; dim];
178
179        let sigma: Vec<f64> = log_sigma.iter().map(|&ls| ls.exp()).collect();
180
181        // MC estimate of ∂E_q[log p(z)] / ∂(μ, log_σ)
182        for _ in 0..config.mc_samples {
183            // Sample ε ~ N(0, I) using the concrete rng type
184            let eps: Vec<f64> = (0..dim).map(|_| rng.sample(normal_dist)).collect();
185
186            // z = μ + σ ⊙ ε  (reparameterisation)
187            let z: Vec<f64> = mu
188                .iter()
189                .zip(sigma.iter())
190                .zip(eps.iter())
191                .map(|((&m, &s), &e)| m + s * e)
192                .collect();
193
194            // Central finite differences: ∂log_p(z)/∂z_i ≈ (log_p(z+h) - log_p(z-h)) / 2h
195            let grad_log_p = compute_fd_gradient(&log_prob, &z, fd_h);
196
197            // Accumulate gradients
198            for i in 0..dim {
199                grad_mu[i] += grad_log_p[i];
200                // Reparameterisation gradient w.r.t. log_σ_i:
201                // ∂z_i/∂log_σ_i = σ_i · ε_i  (chain rule through z = μ + exp(log_σ)*ε)
202                grad_ls[i] += grad_log_p[i] * eps[i] * sigma[i];
203            }
204        }
205
206        let inv_s = 1.0 / config.mc_samples as f64;
207        for i in 0..dim {
208            grad_mu[i] *= inv_s;
209            // Analytic entropy gradient ∂H/∂log_σ_i = 1
210            grad_ls[i] = grad_ls[i] * inv_s + 1.0;
211        }
212
213        // ---- Adam update for μ -----------------------------------------
214        for i in 0..dim {
215            m_mu[i] = beta1 * m_mu[i] + (1.0 - beta1) * grad_mu[i];
216            v_mu[i] = beta2 * v_mu[i] + (1.0 - beta2) * grad_mu[i].powi(2);
217            let m_hat = m_mu[i] / (1.0 - beta1.powi(adam_t as i32));
218            let v_hat = v_mu[i] / (1.0 - beta2.powi(adam_t as i32));
219            mu[i] += config.learning_rate * m_hat / (v_hat.sqrt() + adam_eps);
220        }
221
222        // ---- Adam update for log_σ -------------------------------------
223        for i in 0..dim {
224            m_ls[i] = beta1 * m_ls[i] + (1.0 - beta1) * grad_ls[i];
225            v_ls[i] = beta2 * v_ls[i] + (1.0 - beta2) * grad_ls[i].powi(2);
226            let m_hat = m_ls[i] / (1.0 - beta1.powi(adam_t as i32));
227            let v_hat = v_ls[i] / (1.0 - beta2.powi(adam_t as i32));
228            log_sigma[i] += config.learning_rate * m_hat / (v_hat.sqrt() + adam_eps);
229        }
230    }
231
232    Ok(MeanFieldGaussian { mu, log_sigma })
233}
234
235// ============================================================================
236// Finite-difference gradient helper
237// ============================================================================
238
239/// Compute the gradient of `f` at `z` via central finite differences with step `h`.
240fn compute_fd_gradient(f: &impl Fn(&[f64]) -> f64, z: &[f64], h: f64) -> Vec<f64> {
241    let dim = z.len();
242    let mut grad = Vec::with_capacity(dim);
243    let mut z_plus = z.to_vec();
244    let mut z_minus = z.to_vec();
245
246    for i in 0..dim {
247        z_plus[i] = z[i] + h;
248        z_minus[i] = z[i] - h;
249        let g = (f(&z_plus) - f(&z_minus)) / (2.0 * h);
250        grad.push(g);
251        z_plus[i] = z[i];
252        z_minus[i] = z[i];
253    }
254    grad
255}
256
257// ============================================================================
258// Tests
259// ============================================================================
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn mfg_dim() {
267        let mfg = MeanFieldGaussian {
268            mu: vec![1.0, 2.0, 3.0],
269            log_sigma: vec![0.0, 0.0, 0.0],
270        };
271        assert_eq!(mfg.dim(), 3);
272    }
273
274    #[test]
275    fn mfg_sigma() {
276        let log_sigma = vec![-1.0, 0.0, 1.0];
277        let mfg = MeanFieldGaussian {
278            mu: vec![0.0; 3],
279            log_sigma: log_sigma.clone(),
280        };
281        let sigma = mfg.sigma();
282        for (got, &ls) in sigma.iter().zip(log_sigma.iter()) {
283            assert!(
284                (got - ls.exp()).abs() < 1e-12,
285                "sigma mismatch: got {got}, expected {}",
286                ls.exp()
287            );
288        }
289    }
290
291    #[test]
292    fn vi_recovers_gaussian_mean() {
293        // Target: log p(z) = -0.5 * ||z - mu_true||^2 / sigma_true^2
294        // → posterior mean should converge to mu_true = [2.0, 3.0]
295        let mu_true = [2.0_f64, 3.0_f64];
296        let sigma_true = 1.0_f64;
297        let log_prob = move |z: &[f64]| {
298            -0.5 * z
299                .iter()
300                .zip(mu_true.iter())
301                .map(|(&zi, &mi)| ((zi - mi) / sigma_true).powi(2))
302                .sum::<f64>()
303        };
304
305        let config = VariationalConfig {
306            steps: 2000,
307            learning_rate: 0.05,
308            mc_samples: 20,
309            seed: Some(42),
310        };
311        let mfg = VariationalInference::fit(log_prob, 2, config).expect("fit failed");
312
313        assert!(
314            (mfg.mu[0] - mu_true[0]).abs() < 0.3,
315            "mu[0]={} not close to {}",
316            mfg.mu[0],
317            mu_true[0]
318        );
319        assert!(
320            (mfg.mu[1] - mu_true[1]).abs() < 0.3,
321            "mu[1]={} not close to {}",
322            mfg.mu[1],
323            mu_true[1]
324        );
325    }
326
327    #[test]
328    fn vi_recovers_gaussian_variance() {
329        // Same target: posterior variance should converge to sigma_true^2 = 1.0
330        let mu_true = [2.0_f64, 3.0_f64];
331        let sigma_true = 1.0_f64;
332        let log_prob = move |z: &[f64]| {
333            -0.5 * z
334                .iter()
335                .zip(mu_true.iter())
336                .map(|(&zi, &mi)| ((zi - mi) / sigma_true).powi(2))
337                .sum::<f64>()
338        };
339
340        let config = VariationalConfig {
341            steps: 2000,
342            learning_rate: 0.05,
343            mc_samples: 20,
344            seed: Some(42),
345        };
346        let mfg = VariationalInference::fit(log_prob, 2, config).expect("fit failed");
347        let sigma = mfg.sigma();
348
349        // Each σ_i should be close to sigma_true = 1.0 (within 30%)
350        for (i, &s) in sigma.iter().enumerate() {
351            assert!(
352                (s - sigma_true).abs() < 0.3 * sigma_true,
353                "sigma[{i}]={s} not within 30% of {sigma_true}"
354            );
355        }
356    }
357
358    #[test]
359    fn vi_runs_without_error() {
360        // Arbitrary log_prob; just verify no panic/error
361        let log_prob = |z: &[f64]| -z.iter().map(|&v| v.powi(2)).sum::<f64>();
362        let config = VariationalConfig {
363            steps: 50,
364            learning_rate: 0.01,
365            mc_samples: 5,
366            seed: Some(7),
367        };
368        VariationalInference::fit(log_prob, 3, config).expect("fit should not fail");
369    }
370}