Skip to main content

fugue/inference/
validation.rs

1//! Statistical validation and testing utilities for inference algorithms.
2//!
3//! This module provides tools for validating the correctness of inference
4//! implementations using known theoretical results and simulation studies.
5
6use crate::addr;
7use crate::core::distribution::*;
8
9use crate::inference::mcmc_utils::effective_sample_size_mcmc;
10use crate::runtime::trace::{ChoiceValue, Trace};
11use rand::Rng;
12
13/// Kolmogorov-Smirnov test for distribution correctness.
14///
15/// Tests whether samples from our distribution implementation match
16/// the theoretical distribution using the two-sample KS test.
17pub fn ks_test_distribution<R: Rng>(
18    rng: &mut R,
19    dist: &dyn Distribution<f64>,
20    reference_samples: &[f64],
21    n_samples: usize,
22    alpha: f64,
23) -> bool {
24    // Generate samples from our implementation
25    let mut our_samples = Vec::with_capacity(n_samples);
26    for _ in 0..n_samples {
27        our_samples.push(dist.sample(rng));
28    }
29
30    // Sort both sample sets
31    our_samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
32    let mut ref_sorted = reference_samples.to_vec();
33    ref_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
34
35    // Compute KS statistic
36    let ks_stat = ks_statistic(&our_samples, &ref_sorted);
37
38    // Critical value for two-sample KS test
39    let n1 = our_samples.len() as f64;
40    let n2 = ref_sorted.len() as f64;
41    let critical_value = (-0.5 * alpha.ln()).sqrt() * ((n1 + n2) / (n1 * n2)).sqrt();
42
43    ks_stat < critical_value
44}
45
46/// Compute two-sample Kolmogorov-Smirnov statistic.
47fn ks_statistic(sample1: &[f64], sample2: &[f64]) -> f64 {
48    let n1 = sample1.len() as f64;
49    let n2 = sample2.len() as f64;
50
51    let mut max_diff: f64 = 0.0;
52    let mut i1 = 0;
53    let mut i2 = 0;
54
55    while i1 < sample1.len() && i2 < sample2.len() {
56        let cdf1 = (i1 + 1) as f64 / n1;
57        let cdf2 = (i2 + 1) as f64 / n2;
58
59        max_diff = max_diff.max((cdf1 - cdf2).abs());
60
61        if sample1[i1] <= sample2[i2] {
62            i1 += 1;
63        } else {
64            i2 += 1;
65        }
66    }
67
68    max_diff
69}
70
71/// Configuration for conjugate normal model validation.
72#[derive(Debug, Clone)]
73pub struct ConjugateNormalConfig {
74    /// Prior mean
75    pub prior_mu: f64,
76    /// Prior standard deviation
77    pub prior_sigma: f64,
78    /// Likelihood standard deviation
79    pub likelihood_sigma: f64,
80    /// Observed data point
81    pub observation: f64,
82    /// Number of MCMC samples
83    pub n_samples: usize,
84    /// Number of warmup/burn-in samples
85    pub n_warmup: usize,
86}
87
88/// Test MCMC implementation against known analytical posterior.
89///
90/// For conjugate models where the posterior is known analytically,
91/// this validates that MCMC produces the correct distribution.
92pub fn test_conjugate_normal_model<R: Rng>(
93    rng: &mut R,
94    mcmc_fn: impl Fn(&mut R, usize, usize) -> Vec<(f64, Trace)>,
95    config: ConjugateNormalConfig,
96) -> ValidationResult {
97    // Analytical posterior for normal-normal conjugate model
98    let prior_precision = 1.0 / (config.prior_sigma * config.prior_sigma);
99    let likelihood_precision = 1.0 / (config.likelihood_sigma * config.likelihood_sigma);
100
101    let posterior_precision = prior_precision + likelihood_precision;
102    let posterior_variance = 1.0 / posterior_precision;
103    let posterior_mu = posterior_variance
104        * (prior_precision * config.prior_mu + likelihood_precision * config.observation);
105
106    let samples = mcmc_fn(rng, config.n_samples, config.n_warmup);
107    validate_against_analytical_posterior(
108        &samples,
109        &addr!("mu"),
110        posterior_mu,
111        posterior_variance,
112        config.n_samples,
113    )
114}
115
116/// Configuration for conjugate Beta-Bernoulli model validation.
117///
118/// FG-15: complements [`ConjugateNormalConfig`] with the other textbook
119/// conjugate pair (a bounded-support, non-symmetric posterior) so the
120/// harness isn't validated on Normal-Normal alone.
121#[derive(Debug, Clone)]
122pub struct ConjugateBetaBernoulliConfig {
123    /// Prior alpha (pseudo-count of prior successes).
124    pub prior_alpha: f64,
125    /// Prior beta (pseudo-count of prior failures).
126    pub prior_beta: f64,
127    /// Observed i.i.d. Bernoulli outcomes.
128    pub observations: Vec<bool>,
129    /// Number of MCMC samples.
130    pub n_samples: usize,
131    /// Number of warmup/burn-in samples.
132    pub n_warmup: usize,
133}
134
135/// Test MCMC implementation against the known analytical Beta-Bernoulli
136/// conjugate posterior.
137///
138/// For `theta ~ Beta(a, b)` and `n` i.i.d. `Bernoulli(theta)` observations
139/// with `s` successes, the exact posterior is `Beta(a + s, b + n - s)`, with
140/// mean `(a+s)/(a+b+n)` and variance `(a+s)(b+n-s) / ((a+b+n)^2 (a+b+n+1))`.
141/// `mcmc_fn`'s model is expected to sample the success probability at
142/// address `"theta"` (mirroring [`test_conjugate_normal_model`]'s use of
143/// `"mu"`).
144pub fn test_conjugate_beta_bernoulli_model<R: Rng>(
145    rng: &mut R,
146    mcmc_fn: impl Fn(&mut R, usize, usize) -> Vec<(f64, Trace)>,
147    config: ConjugateBetaBernoulliConfig,
148) -> ValidationResult {
149    let successes = config.observations.iter().filter(|&&b| b).count() as f64;
150    let n = config.observations.len() as f64;
151    let post_alpha = config.prior_alpha + successes;
152    let post_beta = config.prior_beta + (n - successes);
153    let post_sum = post_alpha + post_beta;
154
155    let posterior_mu = post_alpha / post_sum;
156    let posterior_variance = (post_alpha * post_beta) / (post_sum * post_sum * (post_sum + 1.0));
157
158    let samples = mcmc_fn(rng, config.n_samples, config.n_warmup);
159    validate_against_analytical_posterior(
160        &samples,
161        &addr!("theta"),
162        posterior_mu,
163        posterior_variance,
164        config.n_samples,
165    )
166}
167
168/// Shared scoring logic for the conjugate-model validation harnesses:
169/// extract the `f64` trace values at `address`, compare their sample
170/// mean/variance to the supplied analytical posterior mean/variance within
171/// 2 Monte Carlo standard errors (computed from the effective sample size),
172/// and check the chain achieved at least 10% sampling efficiency.
173fn validate_against_analytical_posterior(
174    samples: &[(f64, Trace)],
175    address: &crate::core::address::Address,
176    posterior_mu: f64,
177    posterior_variance: f64,
178    n_samples: usize,
179) -> ValidationResult {
180    let posterior_sigma = posterior_variance.sqrt();
181
182    let param_samples: Vec<f64> = samples
183        .iter()
184        .filter_map(|(_, trace)| trace.choices.get(address))
185        .filter_map(|choice| match choice.value {
186            ChoiceValue::F64(val) => Some(val),
187            _ => None,
188        })
189        .collect();
190
191    if param_samples.is_empty() {
192        return ValidationResult::Failed("No samples extracted".to_string());
193    }
194
195    // Compute sample statistics
196    let sample_mean = param_samples.iter().sum::<f64>() / param_samples.len() as f64;
197    let sample_var = param_samples
198        .iter()
199        .map(|&x| (x - sample_mean).powi(2))
200        .sum::<f64>()
201        / (param_samples.len() - 1) as f64;
202    let sample_sigma = sample_var.sqrt();
203
204    // Compute effective sample size
205    let ess = effective_sample_size_mcmc(&param_samples);
206
207    // Check if estimates are within reasonable bounds (2 standard errors)
208    let se_mean = posterior_sigma / (ess.sqrt());
209    let se_var = posterior_variance * (2.0 / ess).sqrt();
210
211    let mean_error = (sample_mean - posterior_mu).abs();
212    let var_error = (sample_var - posterior_variance).abs();
213
214    let mean_ok = mean_error < 2.0 * se_mean;
215    let var_ok = var_error < 2.0 * se_var;
216    let ess_ok = ess > n_samples as f64 * 0.1; // At least 10% efficiency
217
218    ValidationResult::Success {
219        mean_error,
220        var_error,
221        effective_sample_size: ess,
222        mean_within_bounds: mean_ok,
223        var_within_bounds: var_ok,
224        ess_adequate: ess_ok,
225        posterior_mu,
226        posterior_sigma,
227        sample_mean,
228        sample_sigma,
229    }
230}
231
232/// Result of statistical validation test.
233#[derive(Debug)]
234pub enum ValidationResult {
235    Success {
236        mean_error: f64,
237        var_error: f64,
238        effective_sample_size: f64,
239        mean_within_bounds: bool,
240        var_within_bounds: bool,
241        ess_adequate: bool,
242        posterior_mu: f64,
243        posterior_sigma: f64,
244        sample_mean: f64,
245        sample_sigma: f64,
246    },
247    Failed(String),
248}
249
250impl ValidationResult {
251    pub fn is_valid(&self) -> bool {
252        match self {
253            ValidationResult::Success {
254                mean_within_bounds,
255                var_within_bounds,
256                ess_adequate,
257                ..
258            } => *mean_within_bounds && *var_within_bounds && *ess_adequate,
259            ValidationResult::Failed(_) => false,
260        }
261    }
262
263    pub fn print_summary(&self) {
264        match self {
265            ValidationResult::Success {
266                mean_error,
267                var_error,
268                effective_sample_size,
269                posterior_mu,
270                posterior_sigma,
271                sample_mean,
272                sample_sigma,
273                mean_within_bounds,
274                var_within_bounds,
275                ess_adequate,
276            } => {
277                println!("Validation Results:");
278                println!(
279                    "  True posterior: N({:.4}, {:.4})",
280                    posterior_mu, posterior_sigma
281                );
282                println!(
283                    "  Sample estimates: N({:.4}, {:.4})",
284                    sample_mean, sample_sigma
285                );
286                println!(
287                    "  Mean error: {:.6} ({})",
288                    mean_error,
289                    if *mean_within_bounds { "PASS" } else { "FAIL" }
290                );
291                println!(
292                    "  Var error: {:.6} ({})",
293                    var_error,
294                    if *var_within_bounds { "PASS" } else { "FAIL" }
295                );
296                println!(
297                    "  ESS: {:.1} ({})",
298                    effective_sample_size,
299                    if *ess_adequate { "PASS" } else { "FAIL" }
300                );
301                println!(
302                    "  Overall: {}",
303                    if self.is_valid() { "PASS" } else { "FAIL" }
304                );
305            }
306            ValidationResult::Failed(msg) => {
307                println!("Validation FAILED: {}", msg);
308            }
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests_more {
315    use super::*;
316    use rand::rngs::StdRng;
317    use rand::SeedableRng;
318
319    #[test]
320    fn ks_test_edge_thresholds_and_print_summary() {
321        let mut rng = StdRng::seed_from_u64(50);
322        let normal = Normal::new(0.0, 1.0).unwrap();
323        let ref_samples: Vec<f64> = (0..200).map(|_| normal.sample(&mut rng)).collect();
324        let ok = ks_test_distribution(&mut rng, &normal, &ref_samples, 200, 0.05);
325        assert!(ok);
326
327        // ValidationResult print_summary coverage
328        let res = ValidationResult::Success {
329            mean_error: 0.0,
330            var_error: 0.0,
331            effective_sample_size: 10.0,
332            mean_within_bounds: true,
333            var_within_bounds: true,
334            ess_adequate: true,
335            posterior_mu: 0.0,
336            posterior_sigma: 1.0,
337            sample_mean: 0.0,
338            sample_sigma: 1.0,
339        };
340        res.print_summary();
341        assert!(res.is_valid());
342    }
343}
344
345#[cfg(test)]
346mod validation_tests {
347    use super::*;
348    use rand::rngs::StdRng;
349    use rand::SeedableRng;
350
351    #[test]
352    fn test_normal_distribution() {
353        let mut rng = StdRng::seed_from_u64(42);
354        let normal = Normal::new(0.0, 1.0).unwrap();
355
356        // Generate reference samples using a different method
357        let reference: Vec<f64> = (0..1000)
358            .map(|_| {
359                let u1: f64 = rng.gen();
360                let u2: f64 = rng.gen();
361                (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
362            })
363            .collect();
364
365        let is_valid = ks_test_distribution(&mut rng, &normal, &reference, 1000, 0.05);
366        assert!(is_valid, "Normal distribution failed KS test");
367    }
368}