use std::num::NonZeroUsize;
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConfidenceInterval {
point: f64,
lo: f64,
hi: f64,
confidence: f64,
}
impl ConfidenceInterval {
pub fn point(&self) -> f64 {
self.point
}
pub fn lo(&self) -> f64 {
self.lo
}
pub fn hi(&self) -> f64 {
self.hi
}
pub fn confidence(&self) -> f64 {
self.confidence
}
pub fn width(&self) -> f64 {
self.hi - self.lo
}
}
#[derive(Clone, Copy, Debug)]
pub struct ClusterBootstrap {
resamples: NonZeroUsize,
confidence: f64,
seed: u64,
}
impl ClusterBootstrap {
pub fn new(resamples: NonZeroUsize, confidence: f64, seed: u64) -> Result<Self> {
if !(confidence.is_finite() && confidence > 0.0 && confidence < 1.0) {
return Err(Error::validation(format!(
"bootstrap confidence must be in (0, 1); got {confidence}"
)));
}
Ok(Self {
resamples,
confidence,
seed,
})
}
pub fn measure(&self, clusters: &[Vec<f64>]) -> Result<ConfidenceInterval> {
validate_clusters(clusters)?;
let point = mean_of_cluster_means(clusters);
let k = clusters.len();
let mut stats = Vec::with_capacity(self.resamples.get());
let mut rng = SplitMix64::new(self.seed);
for _ in 0..self.resamples.get() {
let mut sum_means = 0.0f64;
for _ in 0..k {
let cluster = &clusters[rng.index(k)];
let m = cluster.len();
let mut sum = 0.0f64;
for _ in 0..m {
sum += cluster[rng.index(m)];
}
sum_means += sum / m as f64;
}
stats.push(sum_means / k as f64);
}
Ok(self.percentile_interval(point, stats))
}
pub fn measure_pooled(&self, clusters: &[Vec<f64>]) -> Result<ConfidenceInterval> {
validate_clusters(clusters)?;
let point = pooled_mean(clusters);
let k = clusters.len();
let mut stats = Vec::with_capacity(self.resamples.get());
let mut rng = SplitMix64::new(self.seed);
for _ in 0..self.resamples.get() {
let mut total = 0.0f64;
let mut count = 0usize;
for _ in 0..k {
let cluster = &clusters[rng.index(k)];
let m = cluster.len();
for _ in 0..m {
total += cluster[rng.index(m)];
count += 1;
}
}
stats.push(total / count as f64);
}
Ok(self.percentile_interval(point, stats))
}
pub fn measure_cluster_statistic<T, F>(
&self,
clusters: &[T],
statistic: F,
) -> Result<ConfidenceInterval>
where
T: Clone,
F: Fn(&[T]) -> Result<f64>,
{
if clusters.is_empty() {
return Err(Error::validation("no clusters to bootstrap"));
}
let point = finite_statistic(statistic(clusters)?)?;
let k = clusters.len();
let mut stats = Vec::with_capacity(self.resamples.get());
let mut rng = SplitMix64::new(self.seed);
for _ in 0..self.resamples.get() {
let mut sample = Vec::with_capacity(k);
for _ in 0..k {
sample.push(clusters[rng.index(k)].clone());
}
stats.push(finite_statistic(statistic(&sample)?)?);
}
Ok(self.percentile_interval(point, stats))
}
fn percentile_interval(&self, point: f64, mut stats: Vec<f64>) -> ConfidenceInterval {
stats.sort_by(f64::total_cmp);
let alpha = 1.0 - self.confidence;
ConfidenceInterval {
point,
lo: quantile_sorted(&stats, alpha / 2.0),
hi: quantile_sorted(&stats, 1.0 - alpha / 2.0),
confidence: self.confidence,
}
}
}
fn validate_clusters(clusters: &[Vec<f64>]) -> Result<()> {
if clusters.is_empty() {
return Err(Error::validation("no clusters to bootstrap"));
}
for cluster in clusters {
if cluster.is_empty() {
return Err(Error::validation(
"every cluster must have at least one observation",
));
}
if let Some(bad) = cluster.iter().find(|v| !v.is_finite()) {
return Err(Error::validation(format!(
"bootstrap observations must be finite; found {bad}"
)));
}
}
Ok(())
}
fn finite_statistic(value: f64) -> Result<f64> {
if value.is_finite() {
Ok(value)
} else {
Err(Error::validation(format!(
"bootstrap statistic must be finite; found {value}"
)))
}
}
fn pooled_mean(clusters: &[Vec<f64>]) -> f64 {
let total: f64 = clusters.iter().flatten().copied().sum();
let count: usize = clusters.iter().map(Vec::len).sum();
total / count as f64
}
fn mean_of_cluster_means(clusters: &[Vec<f64>]) -> f64 {
let k = clusters.len() as f64;
clusters
.iter()
.map(|c| c.iter().sum::<f64>() / c.len() as f64)
.sum::<f64>()
/ k
}
fn quantile_sorted(xs: &[f64], q: f64) -> f64 {
if xs.len() == 1 {
return xs[0];
}
let h = q.clamp(0.0, 1.0) * (xs.len() as f64 - 1.0);
let lo = h.floor() as usize;
let hi = h.ceil() as usize;
xs[lo] + (xs[hi] - xs[lo]) * (h - lo as f64)
}
pub(crate) struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
pub(crate) fn new(seed: u64) -> Self {
Self { state: seed }
}
pub(crate) fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
pub(crate) fn index(&mut self, n: usize) -> usize {
((u128::from(self.next_u64()) * n as u128) >> 64) as usize
}
}
#[cfg(test)]
mod tests {
use super::*;
fn boot(resamples: usize, confidence: f64, seed: u64) -> ClusterBootstrap {
ClusterBootstrap::new(NonZeroUsize::new(resamples).unwrap(), confidence, seed).unwrap()
}
#[test]
fn point_is_mean_of_cluster_means() {
let clusters = vec![vec![1.0, 3.0], vec![5.0]];
let ci = boot(500, 0.95, 1).measure(&clusters).unwrap();
assert!((ci.point() - 3.5).abs() < 1e-12, "{}", ci.point());
}
#[test]
fn pooled_point_weights_by_observation() {
let clusters = vec![vec![1.0, 3.0], vec![5.0]];
let ci = boot(500, 0.95, 1).measure_pooled(&clusters).unwrap();
assert!((ci.point() - 3.0).abs() < 1e-12, "{}", ci.point());
}
#[test]
fn pooled_coincides_with_mean_of_means_for_equal_clusters() {
let clusters = vec![vec![1.0, 4.0], vec![2.0, 5.0], vec![9.0, -3.0]];
let equal = boot(500, 0.95, 1234).measure(&clusters).unwrap();
let pooled = boot(500, 0.95, 1234).measure_pooled(&clusters).unwrap();
assert_eq!(equal, pooled);
}
#[test]
fn degenerate_data_has_zero_width() {
let clusters = vec![vec![2.0], vec![2.0, 2.0], vec![2.0]];
let ci = boot(256, 0.95, 7).measure(&clusters).unwrap();
assert_eq!(ci.point(), 2.0);
assert_eq!(ci.lo(), 2.0);
assert_eq!(ci.hi(), 2.0);
assert_eq!(ci.width(), 0.0);
}
#[test]
fn single_singleton_cluster_collapses() {
let ci = boot(64, 0.9, 3).measure(&[vec![4.0]]).unwrap();
assert_eq!(ci.point(), 4.0);
assert_eq!(ci.lo(), 4.0);
assert_eq!(ci.hi(), 4.0);
}
#[test]
fn bounds_lie_within_observation_range() {
let clusters = vec![vec![1.0, 2.0], vec![3.0], vec![4.0, 5.0, 6.0]];
let ci = boot(1000, 0.95, 42).measure(&clusters).unwrap();
assert!(ci.lo() >= 1.0, "lo {} below min", ci.lo());
assert!(ci.hi() <= 6.0, "hi {} above max", ci.hi());
assert!(ci.lo() <= ci.point() + 1e-9 && ci.point() <= ci.hi() + 1e-9);
assert!(ci.width() > 0.0);
}
#[test]
fn higher_confidence_is_wider() {
let clusters = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0], vec![10.0]];
let narrow = boot(1000, 0.80, 99).measure(&clusters).unwrap();
let wide = boot(1000, 0.99, 99).measure(&clusters).unwrap();
assert!(
wide.width() >= narrow.width() - 1e-12,
"0.99 width {} < 0.80 width {}",
wide.width(),
narrow.width()
);
}
#[test]
fn is_deterministic() {
let clusters = vec![vec![1.0, 4.0], vec![2.0], vec![9.0, -3.0]];
let a = boot(500, 0.95, 1234).measure(&clusters).unwrap();
let b = boot(500, 0.95, 1234).measure(&clusters).unwrap();
assert_eq!(a, b);
}
#[test]
fn rejects_invalid_inputs() {
let b = boot(100, 0.95, 0);
assert!(b.measure(&[]).is_err());
assert!(b.measure(&[vec![1.0], vec![]]).is_err());
assert!(b.measure(&[vec![1.0, f64::NAN]]).is_err());
assert!(b.measure(&[vec![f64::INFINITY]]).is_err());
assert!(b.measure_pooled(&[]).is_err());
assert!(b.measure_pooled(&[vec![1.0], vec![]]).is_err());
assert!(b.measure_pooled(&[vec![1.0, f64::NAN]]).is_err());
let one = NonZeroUsize::new(100).unwrap();
for bad in [0.0, 1.0, -0.1, 1.5, f64::NAN, f64::INFINITY] {
assert!(
ClusterBootstrap::new(one, bad, 0).is_err(),
"confidence {bad} should be rejected"
);
}
}
#[test]
fn cluster_statistic_bootstrap_resamples_whole_clusters() {
let clusters = vec![vec![1.0, 2.0], vec![10.0], vec![100.0]];
let ci = boot(128, 0.9, 4)
.measure_cluster_statistic(&clusters, |sample| {
Ok(sample.iter().map(|cluster| cluster[0]).sum::<f64>())
})
.unwrap();
assert_eq!(ci.point(), 111.0);
assert!(ci.width() > 0.0);
}
#[test]
fn cluster_statistic_rejects_empty_and_nonfinite() {
let b = boot(16, 0.95, 0);
let empty: Vec<Vec<f64>> = Vec::new();
assert!(
b.measure_cluster_statistic(&empty, |_| unreachable!())
.is_err()
);
assert!(
b.measure_cluster_statistic(&[vec![1.0]], |_| Ok(f64::NAN))
.is_err()
);
}
use proptest::prelude::*;
fn arb_clusters() -> impl Strategy<Value = Vec<Vec<f64>>> {
proptest::collection::vec(proptest::collection::vec(-50.0f64..50.0, 1..6), 1..10)
}
proptest! {
#[test]
fn bounds_are_ordered_and_in_range(
clusters in arb_clusters(),
confidence in 0.50f64..0.999,
seed in any::<u64>(),
) {
let ci = boot(200, confidence, seed).measure(&clusters).unwrap();
let flat: Vec<f64> = clusters.iter().flatten().copied().collect();
let min = flat.iter().copied().fold(f64::INFINITY, f64::min);
let max = flat.iter().copied().fold(f64::NEG_INFINITY, f64::max);
prop_assert!(ci.lo() <= ci.hi());
prop_assert!(ci.lo() >= min - 1e-9 && ci.hi() <= max + 1e-9);
prop_assert!(ci.point() >= min - 1e-9 && ci.point() <= max + 1e-9);
}
#[test]
fn point_equals_independent_mean_of_means(clusters in arb_clusters()) {
let ci = boot(64, 0.95, 5).measure(&clusters).unwrap();
let want = mean_of_cluster_means(&clusters);
prop_assert!((ci.point() - want).abs() <= 1e-9 * want.abs().max(1.0));
}
#[test]
fn pooled_point_equals_flat_mean(clusters in arb_clusters()) {
let ci = boot(64, 0.95, 5).measure_pooled(&clusters).unwrap();
let flat: Vec<f64> = clusters.iter().flatten().copied().collect();
let want = flat.iter().sum::<f64>() / flat.len() as f64;
prop_assert!((ci.point() - want).abs() <= 1e-9 * want.abs().max(1.0));
}
#[test]
fn confidence_is_monotone_in_width(
clusters in arb_clusters(),
lower in 0.50f64..0.90,
delta in 0.01f64..0.09,
seed in any::<u64>(),
) {
let wide = boot(200, lower + delta, seed).measure(&clusters).unwrap();
let narrow = boot(200, lower, seed).measure(&clusters).unwrap();
prop_assert!(wide.width() >= narrow.width() - 1e-9);
}
#[test]
fn is_reproducible(
clusters in arb_clusters(),
confidence in 0.50f64..0.999,
seed in any::<u64>(),
) {
let a = boot(128, confidence, seed).measure(&clusters).unwrap();
let b = boot(128, confidence, seed).measure(&clusters).unwrap();
prop_assert_eq!(a, b);
}
}
}