Skip to main content

stats_claw/algorithms/density/
mod.rs

1//! Kernel density estimation (KDE) with a Gaussian kernel.
2//!
3//! Estimates a continuous probability density from a one-dimensional sample by
4//! summing a Gaussian bump over every observation. The kernel bandwidth follows
5//! Scott's rule with the same covariance factor `scipy.stats.gaussian_kde` uses
6//! (`factor = n^(−1/5)` in one dimension, `bandwidth² = factor² · var(data, ddof=1)`),
7//! so the estimate reproduces scipy's `gaussian_kde` to machine precision. This
8//! base block backs the `DensityEstimation` computational method.
9
10mod kernel;
11
12use kernel::{count_to_f64, sample_variance};
13
14/// `2π`, the constant in the Gaussian normalising factor `1/√(2π h²)`.
15const TWO_PI: f64 = std::f64::consts::TAU;
16
17/// A fitted Gaussian kernel density estimator over a one-dimensional sample.
18///
19/// Holds the observations and the squared bandwidth (kernel variance) derived
20/// from them, so density queries reuse a single bandwidth computed once at fit
21/// time. Fields are private to keep the sample and its derived bandwidth an
22/// invariant pair; query the estimate with [`GaussianKde::density`] /
23/// [`GaussianKde::density_at`].
24#[derive(Debug, Clone, PartialEq)]
25pub struct GaussianKde {
26    /// The fitted one-dimensional observations.
27    data: Vec<f64>,
28    /// The kernel variance `h²` (squared bandwidth) shared by every kernel.
29    variance: f64,
30}
31
32/// Errors that prevent a Gaussian KDE from being fitted.
33///
34/// Returned (never panicked) so callers stay clear of the crate's `unwrap`/`panic`
35/// lint gate and can surface a clean diagnostic.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum DensityError {
38    /// The sample was empty, so no density can be estimated.
39    EmptyInput,
40    /// The sample had only one observation, so its variance — and hence Scott's
41    /// bandwidth — is undefined (degenerate).
42    SinglePoint,
43    /// Every observation was identical (zero variance), so the bandwidth collapses
44    /// to zero and the estimate is a degenerate spike.
45    ZeroVariance,
46    /// An observation was non-finite (`NaN` or `±∞`), which has no valid density.
47    NonFinite,
48}
49
50impl std::fmt::Display for DensityError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::EmptyInput => write!(f, "sample has no observations"),
54            Self::SinglePoint => write!(f, "sample needs at least two observations"),
55            Self::ZeroVariance => write!(f, "sample has zero variance"),
56            Self::NonFinite => write!(f, "sample contains a non-finite value"),
57        }
58    }
59}
60
61impl std::error::Error for DensityError {}
62
63impl GaussianKde {
64    /// The kernel variance `h²` (squared bandwidth) shared by every kernel.
65    #[must_use]
66    pub const fn variance(&self) -> f64 {
67        self.variance
68    }
69
70    /// The kernel bandwidth `h` (standard deviation of each Gaussian bump).
71    #[must_use]
72    pub fn bandwidth(&self) -> f64 {
73        self.variance.sqrt()
74    }
75
76    /// Evaluates the estimated density at a single query point `x`.
77    ///
78    /// # Arguments
79    ///
80    /// * `x` — the point at which to evaluate the density.
81    ///
82    /// # Returns
83    ///
84    /// The estimated density `f(x) ≥ 0`.
85    #[must_use]
86    pub fn density_at(&self, x: f64) -> f64 {
87        // f(x) = (1/n) Σ_i N(x; xᵢ, h²); the 1/√(2π h²) normaliser is shared.
88        let n = count_to_f64(self.data.len());
89        let norm = 1.0 / (TWO_PI * self.variance).sqrt();
90        let inv_two_var = 1.0 / (2.0 * self.variance);
91        let sum: f64 = self
92            .data
93            .iter()
94            .map(|&xi| {
95                let d = x - xi;
96                (-d * d * inv_two_var).exp()
97            })
98            .sum();
99        norm * sum / n
100    }
101
102    /// Evaluates the estimated density at every query point in `xs`.
103    ///
104    /// # Arguments
105    ///
106    /// * `xs` — the points at which to evaluate the density.
107    ///
108    /// # Returns
109    ///
110    /// One estimated density per query point, in the input order.
111    #[must_use]
112    pub fn density(&self, xs: &[f64]) -> Vec<f64> {
113        xs.iter().map(|&x| self.density_at(x)).collect()
114    }
115}
116
117/// Fits a Gaussian kernel density estimator to the one-dimensional `data`.
118///
119/// # Arguments
120///
121/// * `data` — the observations to estimate a density from; needs at least two
122///   distinct finite values.
123///
124/// # Returns
125///
126/// A fitted [`GaussianKde`].
127///
128/// # Errors
129///
130/// Returns [`DensityError::EmptyInput`], [`DensityError::SinglePoint`],
131/// [`DensityError::ZeroVariance`], or [`DensityError::NonFinite`].
132///
133/// # Examples
134///
135/// ```
136/// use stats_claw::algorithms::density::gaussian_kde;
137///
138/// // Matches `scipy.stats.gaussian_kde([2, 4, 6]).evaluate([4.0])`.
139/// let kde = gaussian_kde(&[2.0, 4.0, 6.0])?;
140/// let f4 = kde.density_at(4.0);
141/// assert!((f4 - 0.159_078_110_463_200_72).abs() < 1e-9, "f(4) was {f4}");
142/// # Ok::<(), stats_claw::algorithms::density::DensityError>(())
143/// ```
144pub fn gaussian_kde(data: &[f64]) -> Result<GaussianKde, DensityError> {
145    if data.is_empty() {
146        return Err(DensityError::EmptyInput);
147    }
148    if data.iter().any(|v| !v.is_finite()) {
149        return Err(DensityError::NonFinite);
150    }
151    if data.len() < 2 {
152        return Err(DensityError::SinglePoint);
153    }
154    let var = sample_variance(data);
155    if var <= 0.0 {
156        return Err(DensityError::ZeroVariance);
157    }
158    // Scott's rule (scipy's `gaussian_kde` default in 1-D): the covariance factor
159    // is `n^(−1/(d+4)) = n^(−1/5)`, and the kernel variance is `factor² · var`.
160    let n = count_to_f64(data.len());
161    let factor = n.powf(-1.0 / 5.0);
162    let variance = factor * factor * var;
163    Ok(GaussianKde {
164        data: data.to_vec(),
165        variance,
166    })
167}
168
169#[cfg(test)]
170#[path = "tests.rs"]
171mod tests;