stats_claw/algorithms/mod.rs
1//! Unsupervised learning algorithms.
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//! Decomposition/embedding (PCA, factor analysis, ICA, t-SNE, UMAP, LLE) live in
11//! the [`decomposition`] subgroup; they compare to their `scikit-learn` references
12//! under the sign/order/stochastic-aware standards documented there. PELT
13//! change-point detection lives in [`change_point`] and compares to `ruptures` for
14//! exact breakpoint equality. Gaussian kernel density estimation lives in
15//! [`density`] and compares to `scipy.stats.gaussian_kde` (Scott's-rule bandwidth)
16//! to machine precision. Univariate outlier / anomaly detection (z-score, IQR
17//! Tukey fence, modified MAD-based z-score) lives in [`outlier`] and compares to
18//! `scipy.stats.zscore` and `numpy.percentile` to machine precision. Univariate
19//! feature selection (variance threshold + ANOVA F-test `f_classif` score) lives
20//! in [`feature_selection`] and compares to
21//! `sklearn.feature_selection.VarianceThreshold` and
22//! `sklearn.feature_selection.f_classif` (F-scores to ~`1e-9`, p-values to the F
23//! distribution's asymptotic `1e-6` tail band, variances exact). `HyperLogLog`
24//! distinct-count (cardinality) estimation lives in [`cardinality`]; it has no
25//! canonical reference library, so it is checked against the **exact** distinct
26//! count (a `HashSet` ground truth) landing inside a small multiple of
27//! `HyperLogLog`'s `≈ 1.04 / √m` theoretical standard error.
28
29pub mod association;
30pub mod cardinality;
31pub mod change_point;
32pub mod clustering;
33pub mod decomposition;
34pub mod density;
35pub mod feature_selection;
36pub mod outlier;
37pub mod regression;
38
39/// Computes the squared Euclidean distance between two equal-length points.
40///
41/// The square root is omitted: clustering routines compare and accumulate
42/// distances where the monotonic squared form is both faster and more accurate
43/// (it avoids a `sqrt` round-trip), and inertia is defined as a sum of squared
44/// distances.
45///
46/// # Arguments
47///
48/// * `a` — first point.
49/// * `b` — second point; must have the same length as `a`. Extra coordinates in
50/// the longer slice are ignored (the zip stops at the shorter length), so
51/// callers are responsible for passing equal-dimension points.
52///
53/// # Returns
54///
55/// `Σ (aᵢ − bᵢ)²`, always `≥ 0` for finite inputs.
56///
57/// # Examples
58///
59/// ```
60/// use stats_claw::algorithms::euclidean_sq;
61///
62/// // (0,0) to (3,4): 9 + 16 = 25.
63/// assert!((euclidean_sq(&[0.0, 0.0], &[3.0, 4.0]) - 25.0).abs() < 1e-12);
64/// ```
65#[must_use]
66pub fn euclidean_sq(a: &[f64], b: &[f64]) -> f64 {
67 a.iter()
68 .zip(b)
69 .map(|(&x, &y)| {
70 let d = x - y;
71 d * d
72 })
73 .sum()
74}
75
76/// Computes the elementwise mean (centroid) of a non-empty set of points.
77///
78/// # Arguments
79///
80/// * `points` — the points to average; each must share the dimension `dim`.
81/// * `dim` — the dimensionality of every point.
82///
83/// # Returns
84///
85/// The centroid as a length-`dim` vector, or a zero vector when `points` is empty
86/// (callers that must distinguish an empty cluster check the count themselves).
87#[must_use]
88pub fn centroid(points: &[&[f64]], dim: usize) -> Vec<f64> {
89 let mut sum = vec![0.0_f64; dim];
90 for &p in points {
91 for (s, &v) in sum.iter_mut().zip(p) {
92 *s += v;
93 }
94 }
95 let n = count_to_f64(points.len());
96 if n > 0.0 {
97 for s in &mut sum {
98 *s /= n;
99 }
100 }
101 sum
102}
103
104/// Widens a `usize` count to `f64` without an `as` cast.
105///
106/// Counts here are sample/cluster sizes far below `2^53`, so splitting into
107/// 32-bit halves and recombining reproduces the value exactly while satisfying the
108/// `style.rs` no-`as` guard.
109///
110/// # Arguments
111///
112/// * `n` — the count to widen.
113///
114/// # Returns
115///
116/// `n` as an `f64` (exact for the sample sizes this crate handles).
117#[must_use]
118pub fn count_to_f64(n: usize) -> f64 {
119 let wide = u64::try_from(n).unwrap_or(u64::MAX);
120 let hi = u32::try_from(wide >> 32).unwrap_or(0);
121 let lo = u32::try_from(wide & 0xFFFF_FFFF).unwrap_or(0);
122 f64::from(hi).mul_add(4_294_967_296.0, f64::from(lo))
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn euclidean_sq_is_zero_for_identical_points() {
131 assert!(euclidean_sq(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).abs() < 1e-12);
132 }
133
134 #[test]
135 fn euclidean_sq_matches_pythagoras() {
136 assert!((euclidean_sq(&[0.0, 0.0], &[3.0, 4.0]) - 25.0).abs() < 1e-12);
137 }
138
139 #[test]
140 fn centroid_averages_coordinates() {
141 let first = [0.0_f64, 0.0];
142 let second = [2.0_f64, 4.0];
143 let mean = centroid(&[&first, &second], 2);
144 assert_eq!(mean.len(), 2, "centroid dim");
145 let mean_x = mean.first().copied().unwrap_or(f64::NAN);
146 let mean_y = mean.get(1).copied().unwrap_or(f64::NAN);
147 assert!((mean_x - 1.0).abs() < 1e-12, "x was {mean_x}");
148 assert!((mean_y - 2.0).abs() < 1e-12, "y was {mean_y}");
149 }
150
151 #[test]
152 fn count_to_f64_widens_exactly() {
153 assert!((count_to_f64(150) - 150.0).abs() < 1e-12);
154 }
155}