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;