Skip to main content

stats_claw/algorithms/
mod.rs

1//! Machine-learning algorithms (unsupervised and supervised).
2//!
3//! This module defines the shared algorithm primitives — the squared-Euclidean
4//! distance helper and a small `Matrix` view over row-major data — and houses the
5//! clustering family in the [`clustering`] subgroup folder. Every clustering
6//! routine consumes `&[Vec<f64>]` (one inner `Vec` per observation) and returns a
7//! label vector that the equivalence suite compares to `scikit-learn` by adjusted
8//! Rand index.
9//!
10//! Supervised classification lives in [`classification`] — deterministic,
11//! closed-form Naive Bayes (Gaussian and Categorical variants) reproducing
12//! `sklearn.naive_bayes` semantics, each emitting a `ClassificationResult` with
13//! accuracy plus macro-averaged precision / recall / F1.
14//!
15//! Decomposition/embedding (PCA, factor analysis, ICA, t-SNE, UMAP, LLE) live in
16//! the [`decomposition`] subgroup; they compare to their `scikit-learn` references
17//! under the sign/order/stochastic-aware standards documented there. PELT
18//! change-point detection lives in [`change_point`] and compares to `ruptures` for
19//! exact breakpoint equality. Gaussian kernel density estimation lives in
20//! [`density`] and compares to `scipy.stats.gaussian_kde` (Scott's-rule bandwidth)
21//! to machine precision. Univariate outlier / anomaly detection (z-score, IQR
22//! Tukey fence, modified MAD-based z-score) lives in [`outlier`] and compares to
23//! `scipy.stats.zscore` and `numpy.percentile` to machine precision. Univariate
24//! feature selection (variance threshold + ANOVA F-test `f_classif` score) lives
25//! in [`feature_selection`] and compares to
26//! `sklearn.feature_selection.VarianceThreshold` and
27//! `sklearn.feature_selection.f_classif` (F-scores to ~`1e-9`, p-values to the F
28//! distribution's asymptotic `1e-6` tail band, variances exact). `HyperLogLog`
29//! distinct-count (cardinality) estimation lives in [`cardinality`]; it has no
30//! canonical reference library, so it is checked against the **exact** distinct
31//! count (a `HashSet` ground truth) landing inside a small multiple of
32//! `HyperLogLog`'s `≈ 1.04 / √m` theoretical standard error.
33
34pub mod association;
35pub mod cardinality;
36pub mod change_point;
37pub mod classification;
38pub mod clustering;
39pub mod decomposition;
40pub mod density;
41pub mod feature_selection;
42pub mod outlier;
43pub mod regression;
44
45/// Computes the squared Euclidean distance between two equal-length points.
46///
47/// The square root is omitted: clustering routines compare and accumulate
48/// distances where the monotonic squared form is both faster and more accurate
49/// (it avoids a `sqrt` round-trip), and inertia is defined as a sum of squared
50/// distances.
51///
52/// # Arguments
53///
54/// * `a` — first point.
55/// * `b` — second point; must have the same length as `a`. Extra coordinates in
56///   the longer slice are ignored (the zip stops at the shorter length), so
57///   callers are responsible for passing equal-dimension points.
58///
59/// # Returns
60///
61/// `Σ (aᵢ − bᵢ)²`, always `≥ 0` for finite inputs.
62///
63/// # Examples
64///
65/// ```
66/// use stats_claw::algorithms::euclidean_sq;
67///
68/// // (0,0) to (3,4): 9 + 16 = 25.
69/// assert!((euclidean_sq(&[0.0, 0.0], &[3.0, 4.0]) - 25.0).abs() < 1e-12);
70/// ```
71#[must_use]
72pub fn euclidean_sq(a: &[f64], b: &[f64]) -> f64 {
73    a.iter()
74        .zip(b)
75        .map(|(&x, &y)| {
76            let d = x - y;
77            d * d
78        })
79        .sum()
80}
81
82/// Computes the elementwise mean (centroid) of a non-empty set of points.
83///
84/// # Arguments
85///
86/// * `points` — the points to average; each must share the dimension `dim`.
87/// * `dim` — the dimensionality of every point.
88///
89/// # Returns
90///
91/// The centroid as a length-`dim` vector, or a zero vector when `points` is empty
92/// (callers that must distinguish an empty cluster check the count themselves).
93#[must_use]
94pub fn centroid(points: &[&[f64]], dim: usize) -> Vec<f64> {
95    let mut sum = vec![0.0_f64; dim];
96    for &p in points {
97        for (s, &v) in sum.iter_mut().zip(p) {
98            *s += v;
99        }
100    }
101    let n = count_to_f64(points.len());
102    if n > 0.0 {
103        for s in &mut sum {
104            *s /= n;
105        }
106    }
107    sum
108}
109
110/// Re-export of the shared count-widening helper (see [`crate::numeric`]).
111///
112/// This primitive was originally defined here; the single implementation now
113/// lives in [`crate::numeric::count_to_f64`]. It is demoted from `pub` to
114/// `pub(crate)` — a framework-internal conversion, not part of the public API —
115/// while keeping the `crate::algorithms::count_to_f64` path its many in-crate
116/// callers already use.
117pub(crate) use crate::numeric::count_to_f64;
118
119/// Kani proof harnesses for the shared algorithm primitives.
120///
121/// Compiled only under `cargo kani` (behind `#[cfg(kani)]`); invisible to normal
122/// build/test/clippy. They prove the count widening and the clustering distance
123/// primitive are panic-/overflow-free over symbolic inputs, not sampled ones. Run
124/// with e.g.
125/// `cargo kani -Z stubbing -p stats-claw --harness algo_count_to_f64_faithful`.
126#[cfg(kani)]
127mod verification {
128    use super::{count_to_f64, euclidean_sq};
129
130    /// Magnitude bound on each coordinate, matching the distribution-layer proofs:
131    /// it keeps the squared differences finite so the sum cannot diverge into
132    /// `∞`/`NaN` control flow, isolating the panic-/overflow-freedom argument.
133    const MAX_ABS: f64 = 1e6;
134
135    /// Proves [`count_to_f64`] is panic-/overflow-free and yields a finite,
136    /// non-negative value for *every* symbolic `usize`.
137    ///
138    /// All steps are `try_from`, shifts/masks, and one `mul_add`; none can panic or
139    /// wrap. This is the fast, transcendental-free core the whole algorithms layer
140    /// relies on to widen sample and cluster counts without an `as` cast.
141    #[kani::proof]
142    fn algo_count_to_f64_faithful() {
143        let n: usize = kani::any();
144        let y = count_to_f64(n);
145        assert!(y.is_finite(), "count_to_f64 produced a non-finite value");
146        assert!(y >= 0.0, "count_to_f64 produced a negative value");
147    }
148
149    /// Proves [`euclidean_sq`] — the squared-distance primitive every clustering
150    /// routine accumulates — is panic-/overflow-free and non-negative for two
151    /// symbolic two-dimensional points with bounded finite coordinates.
152    ///
153    /// The `|coordinate| ≤ 1e6` bound keeps each squared difference finite, so the
154    /// non-negativity is a provable property of the `f64` arithmetic (not merely of
155    /// exact reals): a sum of finite squares is finite and `≥ 0`.
156    #[kani::proof]
157    fn algo_euclidean_sq_non_negative() {
158        let a0: f64 = kani::any();
159        let a1: f64 = kani::any();
160        let b0: f64 = kani::any();
161        let b1: f64 = kani::any();
162        for v in [a0, a1, b0, b1] {
163            kani::assume(v.is_finite());
164            kani::assume(v.abs() <= MAX_ABS);
165        }
166        let d = euclidean_sq(&[a0, a1], &[b0, b1]);
167        assert!(d.is_finite(), "euclidean_sq produced a non-finite value");
168        assert!(d >= 0.0, "euclidean_sq produced a negative distance");
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn euclidean_sq_is_zero_for_identical_points() {
178        assert!(euclidean_sq(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).abs() < 1e-12);
179    }
180
181    #[test]
182    fn euclidean_sq_matches_pythagoras() {
183        assert!((euclidean_sq(&[0.0, 0.0], &[3.0, 4.0]) - 25.0).abs() < 1e-12);
184    }
185
186    #[test]
187    fn centroid_averages_coordinates() {
188        let first = [0.0_f64, 0.0];
189        let second = [2.0_f64, 4.0];
190        let mean = centroid(&[&first, &second], 2);
191        assert_eq!(mean.len(), 2, "centroid dim");
192        let mean_x = mean.first().copied().unwrap_or(f64::NAN);
193        let mean_y = mean.get(1).copied().unwrap_or(f64::NAN);
194        assert!((mean_x - 1.0).abs() < 1e-12, "x was {mean_x}");
195        assert!((mean_y - 2.0).abs() < 1e-12, "y was {mean_y}");
196    }
197
198    #[test]
199    fn count_to_f64_widens_exactly() {
200        assert!((count_to_f64(150) - 150.0).abs() < 1e-12);
201    }
202}