Skip to main content

wm_simulation/
rare_event.rs

1//! Rare-event probability estimation.
2//!
3//! Standard Monte Carlo needs ~1/p samples to estimate a probability p —
4//! hopeless for p < 10⁻⁴. Two variance-reduction methods:
5//!
6//! - **Subset simulation** (Au & Beck 2001): iteratively condition on
7//!   intermediate thresholds, each with ~10% failure probability, and
8//!   generate conditional samples with a Metropolis random walk.
9//!   `P(fail) = p₀ᵐ · P(g > t | level m)`.
10//! - **Importance sampling**: sample from a proposal shifted toward the
11//!   failure region and re-weight with the likelihood ratio.
12//!
13//! The limit-state function `g(x)` (failure when `g(x) > threshold`) is
14//! passed as a closure; inputs `x` are standard normal i.i.d. vectors of
15//! dimension `dim`.
16
17use crate::bayesian::rand_u01;
18
19/// Draw a standard normal via Box–Muller from the SplitMix64 state.
20fn randn(state: &mut u64) -> f64 {
21    let u1 = rand_u01(state).max(1e-12);
22    let u2 = rand_u01(state).max(1e-12);
23    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
24}
25
26/// Standard normal PDF.
27fn phi(x: f64) -> f64 {
28    (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
29}
30
31/// Estimate a rare-event probability with subset simulation.
32///
33/// `n_per_level` samples per intermediate level, `n_levels` intermediate
34/// levels, `seed` for the SplitMix64 PRNG. The Metropolis proposal uses a
35/// Gaussian step with `proposal_std` (default 1.0 — in the same units as
36/// the standard-normal inputs).
37#[allow(clippy::too_many_arguments)]
38pub fn subset_simulation<G>(
39    dim: usize,
40    n_per_level: usize,
41    n_levels: usize,
42    threshold: f64,
43    g: G,
44    seed: u64,
45    proposal_std: f64,
46) -> SubsetResult
47where
48    G: Fn(&[f64]) -> f64,
49{
50    let mut rng = seed;
51    let p0 = 0.1_f64; // probability of staying in the next level
52
53    let mut samples: Vec<Vec<f64>> = (0..n_per_level)
54        .map(|_| (0..dim).map(|_| randn(&mut rng)).collect())
55        .collect();
56    let mut g_values: Vec<f64> = samples.iter().map(|s| g(s)).collect();
57
58    let mut level = 0usize;
59
60    while level < n_levels {
61        // Current level threshold: the p0-quantile of g values
62        let mut sorted = g_values.clone();
63        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
64        let idx = ((n_per_level as f64) * (1.0 - p0)) as usize;
65        let level_threshold = sorted[idx.min(n_per_level - 1)];
66
67        if level_threshold >= threshold {
68            // Already past the target — the current population is
69            // conditional on the previous level
70            break;
71        }
72
73        // Keep the top-p0 samples as seeds for the next level
74        let mut seeds: Vec<(Vec<f64>, f64)> = Vec::with_capacity(n_per_level);
75        for (s, gv) in samples.iter().zip(g_values.iter()) {
76            if *gv >= level_threshold {
77                seeds.push((s.clone(), *gv));
78            }
79        }
80        // Seed with the largest values if quantile boundary is fuzzy
81        if seeds.is_empty() {
82            let mut pairs: Vec<(Vec<f64>, f64)> = samples.into_iter().zip(g_values).collect();
83            pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
84            seeds = pairs.into_iter().take(n_per_level.max(1)).collect();
85        }
86
87        // Metropolis-Hastings: generate a fresh population conditional on
88        // g >= level_threshold. Target ∝ φ(x)·1[g(x) ≥ b]; with a symmetric
89        // Gaussian proposal the acceptance ratio is min(1, φ(x')/φ(x)) for
90        // trials inside the level (trials below it are rejected).
91        let mut next: Vec<Vec<f64>> = Vec::with_capacity(n_per_level);
92        let mut next_g: Vec<f64> = Vec::with_capacity(n_per_level);
93        let n_seeds = seeds.len();
94        for i in 0..n_per_level {
95            let seed_sample = seeds[i % n_seeds].0.clone();
96            let mut candidate = seed_sample.clone();
97            let mut candidate_density = density(candidate.iter().copied());
98            let mut accepted = false;
99            for _ in 0..500 {
100                let mut trial = candidate.clone();
101                for v in &mut trial {
102                    *v = proposal_std.mul_add(randn(&mut rng), *v);
103                }
104                let g_trial = g(&trial);
105                if g_trial < level_threshold {
106                    continue;
107                }
108                let trial_density = density(trial.iter().copied());
109                let ratio = (trial_density / candidate_density).min(1.0);
110                if rand_u01(&mut rng) < ratio {
111                    candidate = trial;
112                    candidate_density = trial_density;
113                    accepted = true;
114                    break;
115                }
116            }
117            let gv = if accepted {
118                g(&candidate)
119            } else {
120                seeds[i % n_seeds].1
121            };
122            next_g.push(gv);
123            next.push(candidate);
124        }
125        samples = next;
126        g_values = next_g;
127        level += 1;
128    }
129
130    // P(fail) = p0^level · P(g > threshold | current level)
131    let fail = g_values.iter().filter(|&&v| v >= threshold).count();
132    let conditional = fail as f64 / g_values.len().max(1) as f64;
133    let probability = p0.powf(level as f64) * conditional;
134
135    SubsetResult {
136        probability,
137        levels_used: level,
138        n_samples_total: n_per_level * (level + 1),
139        method: "subset".into(),
140    }
141}
142
143/// Estimate a rare-event probability with importance sampling.
144///
145/// The proposal is `N(δ, I)` with `δ` proportional to the dimension and
146/// threshold (a crude but robust shift); weights are the exact likelihood
147/// ratio `φ(x)/φ(x − δ)`.
148pub fn importance_sampling<G>(
149    dim: usize,
150    n_samples: usize,
151    threshold: f64,
152    g: G,
153    seed: u64,
154) -> ImportanceResult
155where
156    G: Fn(&[f64]) -> f64,
157{
158    let mut rng = seed;
159    // Shift magnitude: ~threshold / sqrt(dim) toward the failure region.
160    // For g(x) = ||x||² (chi-square) the failure region is a shell at
161    // radius sqrt(threshold), so shifting the mean to radius
162    // 0.5·sqrt(threshold) covers it well.
163    let shift = 0.5 * threshold.sqrt().min(8.0) / dim.max(1) as f64;
164
165    let mut count = 0usize;
166    let mut weight_sum = 0.0_f64;
167    for _ in 0..n_samples {
168        let x: Vec<f64> = (0..dim).map(|_| randn(&mut rng) + shift).collect();
169        let w = likelihood_ratio(&x, shift);
170        if g(&x) >= threshold {
171            count += 1;
172            weight_sum += w;
173        }
174    }
175    // Unbiased estimate: mean of 1[g≥t]·w, with coefficient of variation
176    let mean = weight_sum / n_samples.max(1) as f64;
177    let cv = if mean > 1e-300 {
178        // approximate std via the indicator variance of the weighted mean
179        (count as f64).sqrt() / n_samples.max(1) as f64 / mean.max(1e-300)
180    } else {
181        0.0
182    };
183    ImportanceResult {
184        probability: mean,
185        coefficient_of_variation: cv,
186        hits: count,
187        n_samples,
188        method: "importance".into(),
189    }
190}
191
192/// Likelihood ratio between N(0, I) and N(shift, I).
193fn likelihood_ratio(x: &[f64], shift: f64) -> f64 {
194    let mut lr = 1.0_f64;
195    for &xi in x {
196        // φ(x)/φ(x-δ) = exp(-0.5 x² + 0.5 (x-δ)²) = exp(-xδ + 0.5δ²)
197        lr *= (-xi).mul_add(shift, 0.5 * shift * shift).exp();
198    }
199    lr
200}
201
202/// Standard normal density of a vector (log-form product of φ).
203fn density(x: impl Iterator<Item = f64>) -> f64 {
204    x.map(phi).product()
205}
206
207/// Result of a subset simulation run.
208#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
209pub struct SubsetResult {
210    /// Estimated failure probability P(g(X) > threshold).
211    pub probability: f64,
212    /// Intermediate levels actually used.
213    pub levels_used: usize,
214    /// Total samples consumed.
215    pub n_samples_total: usize,
216    /// Estimation method.
217    pub method: String,
218}
219
220/// Result of an importance sampling run.
221#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
222pub struct ImportanceResult {
223    /// Estimated failure probability.
224    pub probability: f64,
225    /// Approximate coefficient of variation of the estimate.
226    pub coefficient_of_variation: f64,
227    /// Raw hits of the indicator.
228    pub hits: usize,
229    /// Samples consumed.
230    pub n_samples: usize,
231    /// Estimation method.
232    pub method: String,
233}
234
235#[cfg(test)]
236mod tests {
237    #![allow(clippy::suboptimal_flops)] // test data expressions, not hot paths
238    use super::*;
239
240    /// Analytic test: P(||X||² > 9) for X ~ N(0, I) in dim 2.
241    /// Chi-square(2): P(χ²₂ > 9) = exp(-9/2) ≈ 0.0111.
242    #[test]
243    fn subset_matches_chi_square_tail() {
244        let result = subset_simulation(
245            2,
246            2000,
247            3,
248            9.0,
249            |x: &[f64]| x[0] * x[0] + x[1] * x[1],
250            42,
251            1.0,
252        );
253        assert!(
254            (result.probability - 0.0111).abs() < 0.01,
255            "subset: {} (expected ~0.0111)",
256            result.probability
257        );
258        assert!(result.levels_used >= 1);
259    }
260
261    #[test]
262    fn importance_matches_chi_square_tail() {
263        let result = importance_sampling(2, 50_000, 9.0, |x: &[f64]| x[0] * x[0] + x[1] * x[1], 7);
264        assert!(
265            (result.probability - 0.0111).abs() < 0.01,
266            "importance: {} (expected ~0.0111)",
267            result.probability
268        );
269    }
270
271    #[test]
272    fn subset_common_event_is_close() {
273        // P(χ²₂ > 4) = exp(-2) ≈ 0.135
274        let result = subset_simulation(
275            2,
276            2000,
277            2,
278            4.0,
279            |x: &[f64]| x[0] * x[0] + x[1] * x[1],
280            123,
281            1.0,
282        );
283        assert!(
284            (result.probability - 0.1353).abs() < 0.05,
285            "subset: {} (expected ~0.135)",
286            result.probability
287        );
288    }
289
290    #[test]
291    fn importance_rejects_no_hits_gracefully() {
292        // Threshold far beyond reach — estimate ~0, no panic
293        let result = importance_sampling(2, 1000, 1e9, |x: &[f64]| x[0] * x[0] + x[1] * x[1], 1);
294        assert!(result.probability < 1e-3);
295        assert_eq!(result.hits, 0);
296    }
297
298    #[test]
299    fn subset_always_exceeded_is_one() {
300        // g = ||x||² ≥ 0 always → threshold below 0 → P = 1
301        let result = subset_simulation(
302            2,
303            500,
304            1,
305            -1.0,
306            |x: &[f64]| x[0] * x[0] + x[1] * x[1],
307            9,
308            1.0,
309        );
310        assert!(result.probability > 0.99);
311    }
312}