Skip to main content

stats_claw/resampling/
monte_carlo.rs

1//! Monte-Carlo resampling numerics: simulation-based expectation estimates and
2//! the Phipson–Smyth add-one Monte-Carlo p-value, for the
3//! [`MonteCarloResampling`] scheme.
4//!
5//! Both estimators draw from the deterministic [`SplitMix64`]
6//! PRNG, so a fixed seed reproduces a result bit-for-bit within a given build
7//! and target. Reproducing it bit-for-bit *across* targets additionally needs a
8//! portable simulation closure: [`SplitMix64::next_f64`] qualifies, whereas
9//! [`SplitMix64::standard_normal`] inherits the platform math library's last ulp
10//! and so must be compared with a tolerance. The
11//! p-value uses the `(b + 1) / (n + 1)` correction of Phipson &
12//! Smyth (2010), which keeps the estimate strictly positive and never reports an
13//! impossible zero p-value.
14//!
15//! # Examples
16//!
17//! ```
18//! use stats_claw::resampling::monte_carlo_estimate;
19//! use stats_claw::rng::SplitMix64;
20//!
21//! // Estimate E[U] for U ~ Uniform[0, 1); the true mean is 0.5.
22//! let est = monte_carlo_estimate(10_000, &mut SplitMix64::new(1), |r| r.next_f64())?;
23//! assert!((est.mean() - 0.5).abs() < 4.0 * est.std_error(), "mean was {}", est.mean());
24//! # Ok::<(), stats_claw::error::Error>(())
25//! ```
26
27use crate::error::{Error, Result};
28use crate::numeric::count_to_f64;
29use crate::resampling::MonteCarloResampling;
30use crate::rng::SplitMix64;
31use crate::tests_stat::Alternative;
32
33/// The outcome of a Monte-Carlo expectation estimate.
34///
35/// Bundles the sample mean of the simulated draws with its standard error and the
36/// number of simulations that produced it, so a caller can build a confidence
37/// band (`mean ± z · std_error`) without re-deriving the sample size. The fields
38/// are private; read them through the [`mean`](Self::mean),
39/// [`std_error`](Self::std_error), and [`n_simulations`](Self::n_simulations)
40/// accessors, matching the `CvScores` / `JackknifeEstimate` style.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct MonteCarloEstimate {
43    /// The sample mean of the `n_simulations` simulated values, i.e. the estimate
44    /// of `E[f]`.
45    mean: f64,
46    /// The standard error of the mean: `sd(sims, ddof=1) / sqrt(n_simulations)`.
47    std_error: f64,
48    /// The number of simulations averaged into `mean` (always `>= 2`).
49    n_simulations: usize,
50}
51
52impl MonteCarloEstimate {
53    /// Returns the Monte-Carlo estimate of `E[f]`: the sample mean of the draws.
54    ///
55    /// # Returns
56    ///
57    /// The mean of the `n_simulations` simulated values.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use stats_claw::resampling::monte_carlo_estimate;
63    /// use stats_claw::rng::SplitMix64;
64    ///
65    /// let est = monte_carlo_estimate(10_000, &mut SplitMix64::new(1), |r| r.next_f64())?;
66    /// assert!((est.mean() - 0.5).abs() < 4.0 * est.std_error(), "mean was {}", est.mean());
67    /// # Ok::<(), stats_claw::error::Error>(())
68    /// ```
69    #[must_use]
70    pub const fn mean(&self) -> f64 {
71        self.mean
72    }
73
74    /// Returns the standard error of the mean: `sd(sims, ddof=1) / sqrt(n_sims)`.
75    ///
76    /// # Returns
77    ///
78    /// The standard error of [`mean`](Self::mean); `0.0` when every draw was
79    /// identical.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use stats_claw::resampling::monte_carlo_estimate;
85    /// use stats_claw::rng::SplitMix64;
86    ///
87    /// // A constant simulation has zero spread, hence zero standard error.
88    /// let est = monte_carlo_estimate(8, &mut SplitMix64::new(1), |_r| 2.0)?;
89    /// assert!(est.std_error().abs() < 1e-12, "std_error was {}", est.std_error());
90    /// # Ok::<(), stats_claw::error::Error>(())
91    /// ```
92    #[must_use]
93    pub const fn std_error(&self) -> f64 {
94        self.std_error
95    }
96
97    /// Returns the number of simulation replicates averaged into [`mean`](Self::mean).
98    ///
99    /// # Returns
100    ///
101    /// The simulation count `n_sims` (always `>= 2`).
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use stats_claw::resampling::monte_carlo_estimate;
107    /// use stats_claw::rng::SplitMix64;
108    ///
109    /// let est = monte_carlo_estimate(5_000, &mut SplitMix64::new(1), |r| r.next_f64())?;
110    /// assert_eq!(est.n_simulations(), 5_000);
111    /// # Ok::<(), stats_claw::error::Error>(())
112    /// ```
113    #[must_use]
114    pub const fn n_simulations(&self) -> usize {
115        self.n_simulations
116    }
117}
118
119/// Estimates `E[f]` by Monte-Carlo simulation.
120///
121/// Runs `sim` `n_sims` times against `rng`, then returns the sample mean of the
122/// draws together with its standard error `sd(sims, ddof=1) / sqrt(n_sims)`. The
123/// draws share one deterministic generator, so a fixed seed reproduces the
124/// estimate bit-for-bit within a given build and target — and on every target
125/// too, provided `sim` is itself portable (see the module header).
126///
127/// # Arguments
128///
129/// * `n_sims` — number of simulation replicates; must be `>= 2` so the
130///   `ddof = 1` variance is defined.
131/// * `rng` — the deterministic generator threaded through every replicate.
132/// * `sim` — the simulation closure; each call may advance `rng` and returns one
133///   realised value of `f`.
134///
135/// # Returns
136///
137/// A [`MonteCarloEstimate`] holding the mean, its standard error, and `n_sims`.
138///
139/// # Errors
140///
141/// Returns [`Error::InsufficientData`] when `n_sims < 2` (the sample standard
142/// error is undefined for fewer than two draws).
143///
144/// # Examples
145///
146/// ```
147/// use stats_claw::resampling::monte_carlo_estimate;
148/// use stats_claw::rng::SplitMix64;
149///
150/// let est = monte_carlo_estimate(50_000, &mut SplitMix64::new(3), |r| r.standard_normal())?;
151/// assert!(est.mean().abs() < 4.0 * est.std_error(), "standard-normal mean was {}", est.mean());
152/// # Ok::<(), stats_claw::error::Error>(())
153/// ```
154pub fn monte_carlo_estimate(
155    n_sims: usize,
156    rng: &mut SplitMix64,
157    mut sim: impl FnMut(&mut SplitMix64) -> f64,
158) -> Result<MonteCarloEstimate> {
159    if n_sims < 2 {
160        return Err(Error::InsufficientData);
161    }
162    let mut sims = Vec::with_capacity(n_sims);
163    for _ in 0..n_sims {
164        sims.push(sim(rng));
165    }
166    let n = count_to_f64(n_sims);
167    // Shared two-pass mean and ddof=1 variance; `n_sims >= 2` is guaranteed
168    // above, so the variance denominator is defined.
169    let mean = crate::numeric::mean(&sims);
170    let variance = crate::numeric::sample_variance(&sims);
171    let std_error = variance.sqrt() / n.sqrt();
172    Ok(MonteCarloEstimate {
173        mean,
174        std_error,
175        n_simulations: n_sims,
176    })
177}
178
179/// Computes a Monte-Carlo p-value with the Phipson–Smyth add-one correction.
180///
181/// Draws `n_sims` statistics from the null distribution via `null_sim`, counts how
182/// many `b` are at least as extreme as `observed` under `alternative`, and returns
183/// `(b + 1) / (n_sims + 1)`. The `+1` in both terms is the Phipson & Smyth (2010)
184/// correction: it treats `observed` itself as one draw of the null, so the p-value
185/// is never an impossible zero and stays in `(0, 1]`.
186///
187/// The extremeness rule per `alternative` is:
188/// * [`Alternative::Greater`] — `sim >= observed` (upper tail).
189/// * [`Alternative::Less`] — `sim <= observed` (lower tail).
190/// * [`Alternative::TwoSided`] — `sim.abs() >= observed.abs()`. This plain
191///   magnitude rule assumes the null statistic is already centred on zero; any
192///   centring of `observed`/`sim` is the caller's responsibility.
193///
194/// # Arguments
195///
196/// * `observed` — the statistic actually observed on the real data.
197/// * `n_sims` — number of null replicates to simulate; must be `>= 1`.
198/// * `rng` — the deterministic generator threaded through every replicate.
199/// * `null_sim` — draws one statistic from the null distribution.
200/// * `alternative` — which tail(s) define "at least as extreme".
201///
202/// # Returns
203///
204/// The corrected p-value in `(0, 1]`.
205///
206/// # Errors
207///
208/// Returns [`Error::InsufficientData`] when `n_sims == 0` (no null draws).
209///
210/// # Examples
211///
212/// ```
213/// use stats_claw::resampling::monte_carlo_p_value;
214/// use stats_claw::rng::SplitMix64;
215/// use stats_claw::tests_stat::Alternative;
216///
217/// // An observation past every plausible standard-normal draw hits the floor.
218/// let p = monte_carlo_p_value(
219///     10.0,
220///     1_000,
221///     &mut SplitMix64::new(1),
222///     |r| r.standard_normal(),
223///     Alternative::Greater,
224/// )?;
225/// assert!((p - 1.0 / 1_001.0).abs() < 1e-12, "p was {p}");
226/// # Ok::<(), stats_claw::error::Error>(())
227/// ```
228pub fn monte_carlo_p_value(
229    observed: f64,
230    n_sims: usize,
231    rng: &mut SplitMix64,
232    mut null_sim: impl FnMut(&mut SplitMix64) -> f64,
233    alternative: Alternative,
234) -> Result<f64> {
235    if n_sims == 0 {
236        return Err(Error::InsufficientData);
237    }
238    let abs_observed = observed.abs();
239    let mut at_least_as_extreme = 0usize;
240    for _ in 0..n_sims {
241        let sim = null_sim(rng);
242        let extreme = match alternative {
243            Alternative::Greater => sim >= observed,
244            Alternative::Less => sim <= observed,
245            Alternative::TwoSided => sim.abs() >= abs_observed,
246        };
247        if extreme {
248            at_least_as_extreme += 1;
249        }
250    }
251    let b = count_to_f64(at_least_as_extreme);
252    let n = count_to_f64(n_sims);
253    Ok((b + 1.0) / (n + 1.0))
254}
255
256impl MonteCarloResampling {
257    /// Estimates `E[f]` by simulation, attaching the numeric to the
258    /// scheme type.
259    ///
260    /// This is the framework's inherent-impl entry point: it delegates verbatim to
261    /// the free [`monte_carlo_estimate`] using the explicit `n_sims`, generator,
262    /// and simulation closure. The scheme's configured fields (its
263    /// `number_of_iterations` / `random_seed`) are intentionally not consulted —
264    /// the explicit arguments take precedence so a caller keeps full control of
265    /// the run.
266    ///
267    /// # Arguments
268    ///
269    /// * `n_sims` — number of simulation replicates; must be `>= 2`.
270    /// * `rng` — the deterministic generator threaded through every replicate.
271    /// * `sim` — the simulation closure returning one realised value of `f`.
272    ///
273    /// # Returns
274    ///
275    /// A [`MonteCarloEstimate`] holding the mean, its standard error, and `n_sims`.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`Error::InsufficientData`] when `n_sims < 2`.
280    ///
281    /// # Examples
282    ///
283    /// ```
284    /// use stats_claw::resampling::MonteCarloResampling;
285    /// use stats_claw::rng::SplitMix64;
286    ///
287    /// let scheme = MonteCarloResampling::default();
288    /// let est = scheme.estimate(10_000, &mut SplitMix64::new(5), |r| r.next_f64())?;
289    /// assert!((est.mean() - 0.5).abs() < 4.0 * est.std_error(), "mean was {}", est.mean());
290    /// # Ok::<(), stats_claw::error::Error>(())
291    /// ```
292    // The delegation ignores `self`'s configured fields by design (explicit
293    // arguments win); the method exists to hang the numeric off the scheme type
294    // per the framework's inherent-impl pattern.
295    #[allow(clippy::unused_self)]
296    pub fn estimate(
297        &self,
298        n_sims: usize,
299        rng: &mut SplitMix64,
300        sim: impl FnMut(&mut SplitMix64) -> f64,
301    ) -> Result<MonteCarloEstimate> {
302        monte_carlo_estimate(n_sims, rng, sim)
303    }
304
305    /// Runs a Monte-Carlo estimate using this scheme's own configuration.
306    ///
307    /// Reads the replicate count from
308    /// [`number_of_iterations`](Self::number_of_iterations) and seeds the
309    /// deterministic PRNG from [`random_seed`](Self::random_seed), then delegates
310    /// to [`monte_carlo_estimate`]. The `i64` seed is reinterpreted to `u64`
311    /// bit-for-bit via [`i64::cast_unsigned`] (not a numeric `as` cast, which the
312    /// `style.rs` guard bans), so a positive seed maps to the same magnitude —
313    /// mirroring [`CrossValidation::run`](crate::resampling::CrossValidation::run).
314    /// Unlike [`estimate`](Self::estimate) — which takes an explicit count and
315    /// generator and ignores these fields — `run` *consumes* the scheme's
316    /// configured fields, so the parameter struct is itself executable against the
317    /// numerics.
318    ///
319    /// # Arguments
320    ///
321    /// * `sim` — the simulation closure; each call may advance the seeded
322    ///   generator and returns one realised value of `f`.
323    ///
324    /// # Returns
325    ///
326    /// A [`MonteCarloEstimate`] holding the mean, its standard error, and the
327    /// configured iteration count.
328    ///
329    /// # Errors
330    ///
331    /// * [`Error::InvalidInput`] when `number_of_iterations` is non-positive or
332    ///   unrepresentable as a `usize` count.
333    /// * [`Error::InsufficientData`] when the configured count is below two (the
334    ///   `ddof = 1` standard error is then undefined), propagated from
335    ///   [`monte_carlo_estimate`].
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// use stats_claw::resampling::MonteCarloResampling;
341    ///
342    /// let scheme = MonteCarloResampling {
343    ///     number_of_iterations: 10_000,
344    ///     random_seed: 5,
345    ///     ..Default::default()
346    /// };
347    /// // Estimating E[U] for U ~ Uniform[0, 1): the true mean is 0.5.
348    /// let est = scheme.run(|r| r.next_f64())?;
349    /// assert!((est.mean() - 0.5).abs() < 4.0 * est.std_error(), "mean was {}", est.mean());
350    /// # Ok::<(), stats_claw::error::Error>(())
351    /// ```
352    pub fn run(&self, sim: impl FnMut(&mut SplitMix64) -> f64) -> Result<MonteCarloEstimate> {
353        if self.number_of_iterations <= 0 {
354            return Err(Error::InvalidInput(
355                "number_of_iterations must be positive".to_owned(),
356            ));
357        }
358        let n_sims = usize::try_from(self.number_of_iterations).map_err(|_| {
359            Error::InvalidInput("number_of_iterations exceeds the usize count range".to_owned())
360        })?;
361        let mut rng = SplitMix64::new(self.random_seed.cast_unsigned());
362        monte_carlo_estimate(n_sims, &mut rng, sim)
363    }
364}
365
366/// Kani formal-verification harnesses for the Monte-Carlo estimators.
367///
368/// These prove the input-validation paths (over symbolic replicate counts) and the
369/// Phipson–Smyth p-value bound (over a symbolic observed statistic and symbolic
370/// finite null draws), rather than the sampled runs the `#[cfg(test)]` suite uses.
371/// The simulation/null closures are supplied by the caller, so the transcendental
372/// kernels a real caller might use never enter these proofs — the harnesses verify
373/// the layer's own control flow and arithmetic. Compiled only under `cargo kani`
374/// (behind `#[cfg(kani)]`); invisible to normal build/test/clippy. Run e.g. with
375/// `cargo kani -Z stubbing -p stats-claw --harness resampling_mc_p_value_bounded`.
376#[cfg(kani)]
377mod verification;
378
379#[cfg(test)]
380mod tests;