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}