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}