Skip to main content

sim_lib_numbers_stats/
distribution.rs

1//! Standardized moments and distribution-distance statistics.
2
3use super::{StatsError, StatsResult, mean, validate_values};
4
5/// Explicit finite-sample estimator convention.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum MomentConvention {
8    /// Population central moments divided by population variance powers.
9    Population,
10    /// Fisher-Pearson adjusted skewness and excess kurtosis.
11    UnbiasedSample,
12}
13/// Standardized third and fourth moment report.
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct StandardizedMoments {
16    /// Third standardized moment.
17    pub skewness: f64,
18    /// Fourth standardized moment (not excess for population convention).
19    pub kurtosis: f64,
20    /// Applied estimator convention.
21    pub convention: MomentConvention,
22    /// Sample count.
23    pub samples: usize,
24}
25/// Computes standardized third and fourth moments under an explicit convention.
26pub fn standardized_moments(
27    values: &[f64],
28    convention: MomentConvention,
29) -> StatsResult<StandardizedMoments> {
30    validate_values("standardized_moments", values)?;
31    let minimum = if convention == MomentConvention::UnbiasedSample {
32        4
33    } else {
34        2
35    };
36    if values.len() < minimum {
37        return Err(StatsError::InsufficientInput {
38            metric: "standardized_moments",
39            minimum,
40            actual: values.len(),
41        });
42    }
43    let center = mean(values)?;
44    let n = values.len() as f64;
45    let m2 = values.iter().map(|x| (x - center).powi(2)).sum::<f64>() / n;
46    if m2 == 0.0 {
47        return Err(StatsError::InvalidControl {
48            field: "variance",
49            reason: "standardized moments require positive variance",
50        });
51    }
52    let m3 = values.iter().map(|x| (x - center).powi(3)).sum::<f64>() / n;
53    let m4 = values.iter().map(|x| (x - center).powi(4)).sum::<f64>() / n;
54    let (skewness, kurtosis) = match convention {
55        MomentConvention::Population => (m3 / m2.powf(1.5), m4 / (m2 * m2)),
56        MomentConvention::UnbiasedSample => {
57            let g1 = m3 / m2.powf(1.5);
58            let g2 = m4 / (m2 * m2) - 3.0;
59            (
60                (n * (n - 1.0)).sqrt() / (n - 2.0) * g1,
61                (n - 1.0) / ((n - 2.0) * (n - 3.0)) * ((n + 1.0) * g2 + 6.0),
62            )
63        }
64    };
65    Ok(StandardizedMoments {
66        skewness,
67        kurtosis,
68        convention,
69        samples: values.len(),
70    })
71}
72
73/// Kolmogorov-Smirnov evaluation policy and applicability identity.
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub enum KsMethod {
76    /// Exact empirical statistic; no approximate probability is asserted.
77    ExactStatistic,
78    /// Standard asymptotic Kolmogorov tail approximation.
79    Asymptotic,
80}
81/// One- or two-sample Kolmogorov-Smirnov report.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct KsResult {
84    /// Supremum empirical-CDF distance.
85    pub statistic: f64,
86    /// Optional asymptotic tail probability.
87    pub p_value: Option<f64>,
88    /// Applied method.
89    pub method: KsMethod,
90    /// Effective sample size used by the approximation.
91    pub effective_samples: f64,
92}
93
94/// Computes a one-sample KS statistic against a caller-supplied continuous CDF.
95pub fn kolmogorov_smirnov_one_sample(
96    values: &[f64],
97    cdf: impl Fn(f64) -> f64,
98    method: KsMethod,
99) -> StatsResult<KsResult> {
100    validate_values("kolmogorov_smirnov_one_sample", values)?;
101    let mut sorted = values.to_vec();
102    sorted.sort_by(f64::total_cmp);
103    let n = sorted.len() as f64;
104    let mut d = 0.0_f64;
105    for (i, x) in sorted.into_iter().enumerate() {
106        let p = cdf(x);
107        if !p.is_finite() || !(0.0..=1.0).contains(&p) {
108            return Err(StatsError::ProbabilityOutOfRange {
109                metric: "kolmogorov_smirnov_one_sample",
110                index: Some(i),
111                value: p,
112            });
113        }
114        d = d
115            .max(((i + 1) as f64 / n - p).abs())
116            .max((p - i as f64 / n).abs());
117    }
118    Ok(ks_result(d, n, method))
119}
120/// Computes the two-sample KS statistic. Inputs must represent independent samples.
121pub fn kolmogorov_smirnov_two_sample(
122    left: &[f64],
123    right: &[f64],
124    method: KsMethod,
125) -> StatsResult<KsResult> {
126    validate_values("kolmogorov_smirnov_two_sample:left", left)?;
127    validate_values("kolmogorov_smirnov_two_sample:right", right)?;
128    let mut a = left.to_vec();
129    let mut b = right.to_vec();
130    a.sort_by(f64::total_cmp);
131    b.sort_by(f64::total_cmp);
132    let (mut i, mut j, mut d) = (0, 0, 0.0_f64);
133    while i < a.len() || j < b.len() {
134        let x = match (a.get(i), b.get(j)) {
135            (Some(x), Some(y)) => {
136                if x.total_cmp(y).is_le() {
137                    *x
138                } else {
139                    *y
140                }
141            }
142            (Some(x), None) => *x,
143            (None, Some(y)) => *y,
144            (None, None) => break,
145        };
146        while i < a.len() && a[i] <= x {
147            i += 1
148        }
149        while j < b.len() && b[j] <= x {
150            j += 1
151        }
152        d = d.max((i as f64 / a.len() as f64 - j as f64 / b.len() as f64).abs());
153    }
154    let effective = (a.len() as f64 * b.len() as f64) / (a.len() + b.len()) as f64;
155    Ok(ks_result(d, effective, method))
156}
157fn ks_result(d: f64, effective: f64, method: KsMethod) -> KsResult {
158    let p_value = (method == KsMethod::Asymptotic).then(|| {
159        let lambda = (effective.sqrt() + 0.12 + 0.11 / effective.sqrt()) * d;
160        (1..=100)
161            .map(|k| {
162                let sign = if k % 2 == 1 { 1.0 } else { -1.0 };
163                2.0 * sign * (-2.0 * (k * k) as f64 * lambda * lambda).exp()
164            })
165            .sum::<f64>()
166            .clamp(0.0, 1.0)
167    });
168    KsResult {
169        statistic: d,
170        p_value,
171        method,
172        effective_samples: effective,
173    }
174}