stats_claw/algorithms/classification/mod.rs
1//! Supervised classification algorithms.
2//!
3//! Each fitted model reports its scores through the shared
4//! [`ClassificationResult`] parameter struct.
5//!
6//! The first classifier is deterministic, closed-form **Naive Bayes** in two
7//! variants — Gaussian (continuous features) and Categorical (discrete features)
8//! — living in [`naive_bayes`]. Both reproduce `scikit-learn`'s
9//! `sklearn.naive_bayes.GaussianNB` / `CategoricalNB` semantics exactly: all
10//! scoring is done in log space (no underflow), priors are the training class
11//! frequencies, and `argmax` ties break toward the lower class label. Each fitted
12//! model can emit a populated `ClassificationResult` via its `classification_result`
13//! method, computing accuracy plus macro-averaged precision / recall / F1 from
14//! predictions against the true labels.
15//!
16//! This module also houses the input-validation, log-space, and metric helpers
17//! shared by every classifier in the family; they are module-private and reached
18//! from the submodules.
19
20pub mod types;
21pub use types::*;
22pub mod naive_bayes;
23
24mod categorical;
25mod gaussian;
26
27use crate::algorithms::count_to_f64;
28use crate::error::{Error, Result};
29
30/// Validates a design matrix `x` against its label vector `y` and returns the
31/// shared feature count.
32///
33/// # Arguments
34///
35/// * `x` — one inner `Vec` per observation; every row must share a length.
36/// * `y` — one class label per observation.
37///
38/// # Returns
39///
40/// The number of features (columns) common to every row.
41///
42/// # Errors
43///
44/// * [`Error::EmptyInput`] if `x` or `y` is empty.
45/// * [`Error::InvalidInput`] if `x` and `y` differ in length, if there are zero
46/// features, or if the rows are ragged.
47fn validate_dims<T>(x: &[Vec<T>], y: &[usize]) -> Result<usize> {
48 if x.is_empty() || y.is_empty() {
49 return Err(Error::EmptyInput);
50 }
51 if x.len() != y.len() {
52 return Err(Error::InvalidInput(
53 "x and y must have the same number of rows".to_owned(),
54 ));
55 }
56 let n_features = x.first().map_or(0, Vec::len);
57 if n_features == 0 {
58 return Err(Error::InvalidInput("x has zero features".to_owned()));
59 }
60 if x.iter().any(|row| row.len() != n_features) {
61 return Err(Error::InvalidInput(
62 "all rows must have the same feature count".to_owned(),
63 ));
64 }
65 Ok(n_features)
66}
67
68/// Returns the sorted, de-duplicated class labels present in `y`.
69fn sorted_unique(y: &[usize]) -> Vec<usize> {
70 let mut classes = y.to_vec();
71 classes.sort_unstable();
72 classes.dedup();
73 classes
74}
75
76/// Returns the log-sum-exp of `values`, the numerically stable `ln Σ exp(vᵢ)`.
77///
78/// Used to normalize joint log-likelihoods into log posteriors without leaving
79/// log space. Returns [`f64::NEG_INFINITY`] for an empty slice.
80fn log_sum_exp(values: &[f64]) -> f64 {
81 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
82 if max == f64::NEG_INFINITY {
83 return f64::NEG_INFINITY;
84 }
85 let sum: f64 = values.iter().map(|&v| (v - max).exp()).sum();
86 max + sum.ln()
87}
88
89/// Normalizes a row of joint log-likelihoods into log posteriors (subtracting
90/// the log-sum-exp) so their exponentials sum to 1.
91fn normalize_log(row: &[f64]) -> Vec<f64> {
92 let lse = log_sum_exp(row);
93 row.iter().map(|&v| v - lse).collect()
94}
95
96/// Returns the index of the maximum entry of `row`, breaking ties toward the
97/// lowest index (so, with sorted class labels, toward the lower label).
98fn argmax(row: &[f64]) -> usize {
99 let mut best_idx = 0;
100 let mut best_val = f64::NEG_INFINITY;
101 for (idx, &val) in row.iter().enumerate() {
102 if val > best_val {
103 best_val = val;
104 best_idx = idx;
105 }
106 }
107 best_idx
108}
109
110/// Builds a [`ClassificationResult`] from a set of predictions and true labels.
111///
112/// Computes accuracy plus macro-averaged precision / recall / F1 over `classes`
113/// (the unweighted mean of the per-class metrics, matching `sklearn.metrics`
114/// with `average="macro"`; a per-class metric with a zero denominator
115/// contributes `0.0`), then fills the descriptive string fields.
116///
117/// # Arguments
118///
119/// * `classes` — the sorted class labels the model can emit.
120/// * `predictions` — one predicted label per sample.
121/// * `y_true` — one true label per sample; must match `predictions` in length.
122/// * `method` — a human-readable method name for the descriptive fields.
123///
124/// # Errors
125///
126/// * [`Error::EmptyInput`] if there are no predictions.
127/// * [`Error::InvalidInput`] if `predictions` and `y_true` differ in length.
128fn classification_result_from(
129 classes: &[usize],
130 predictions: &[usize],
131 y_true: &[usize],
132 method: &str,
133) -> Result<ClassificationResult> {
134 if predictions.is_empty() || y_true.is_empty() {
135 return Err(Error::EmptyInput);
136 }
137 if predictions.len() != y_true.len() {
138 return Err(Error::InvalidInput(
139 "predictions and y_true must have the same length".to_owned(),
140 ));
141 }
142 let n = count_to_f64(predictions.len());
143 let correct = predictions
144 .iter()
145 .zip(y_true)
146 .filter(|(p, t)| p == t)
147 .count();
148 let accuracy = count_to_f64(correct) / n;
149
150 let n_classes = count_to_f64(classes.len());
151 let mut precision_sum = 0.0_f64;
152 let mut recall_sum = 0.0_f64;
153 let mut f1_sum = 0.0_f64;
154 for &cls in classes {
155 let mut true_pos = 0.0_f64;
156 let mut pred_pos = 0.0_f64;
157 let mut actual_pos = 0.0_f64;
158 for (&p, &t) in predictions.iter().zip(y_true) {
159 if p == cls {
160 pred_pos += 1.0;
161 }
162 if t == cls {
163 actual_pos += 1.0;
164 }
165 if p == cls && t == cls {
166 true_pos += 1.0;
167 }
168 }
169 let precision = if pred_pos > 0.0 {
170 true_pos / pred_pos
171 } else {
172 0.0
173 };
174 let recall = if actual_pos > 0.0 {
175 true_pos / actual_pos
176 } else {
177 0.0
178 };
179 let denom = precision + recall;
180 let f1 = if denom > 0.0 {
181 2.0 * precision * recall / denom
182 } else {
183 0.0
184 };
185 precision_sum += precision;
186 recall_sum += recall;
187 f1_sum += f1;
188 }
189
190 Ok(ClassificationResult {
191 accuracy,
192 precision: precision_sum / n_classes,
193 recall: recall_sum / n_classes,
194 f1_score: f1_sum / n_classes,
195 result_id: format!("{method} classification"),
196 timestamp: String::new(),
197 description: format!(
198 "{method} scored on {} samples across {} classes",
199 predictions.len(),
200 classes.len()
201 ),
202 })
203}
204
205/// Kani proof harnesses for the shared classification helpers.
206///
207/// Compiled only under `cargo kani` (behind `#[cfg(kani)]`); invisible to normal
208/// build/test/clippy.
209#[cfg(kani)]
210mod verification {
211 use super::{Error, argmax, validate_dims};
212
213 /// Proves [`validate_dims`] never panics and returns the feature count for a
214 /// symbolic `2×2` design matrix paired with a two-element label vector.
215 ///
216 /// The matrix entries are fully symbolic `f64`, and a rectangular two-by-two
217 /// matrix with a matching label length is never empty, never ragged, and has a
218 /// non-zero feature count, so the sole reachable outcome is `Ok(2)`; any `Err`
219 /// here would be a control-flow bug, which the harness rules out.
220 #[kani::proof]
221 fn class_validate_dims_ok() {
222 let a: f64 = kani::any();
223 let b: f64 = kani::any();
224 let c: f64 = kani::any();
225 let d: f64 = kani::any();
226 let x = vec![vec![a, b], vec![c, d]];
227 let y = [0_usize, 1];
228 match validate_dims(&x, &y) {
229 Ok(features) => assert!(features == 2, "feature count was not 2"),
230 Err(Error::EmptyInput | Error::InvalidInput(_)) => {
231 assert!(
232 false,
233 "a rectangular 2x2 matrix with 2 labels must validate"
234 );
235 }
236 Err(_) => assert!(false, "validate_dims returned an unexpected error"),
237 }
238 }
239
240 /// Proves [`argmax`] never panics and returns an in-bounds index for a symbolic
241 /// three-element score row.
242 ///
243 /// The scores are fully symbolic (including `NaN`, for which the strict `>`
244 /// comparison is always false, leaving the running best index unchanged), so the
245 /// returned index is provably `< 3` for every combination — the guarantee the
246 /// class-label lookup that follows `argmax` depends on. The `#[kani::unwind(4)]`
247 /// unrolls the fixed three-element scan.
248 #[kani::proof]
249 #[kani::unwind(4)]
250 fn class_argmax_in_bounds() {
251 let a: f64 = kani::any();
252 let b: f64 = kani::any();
253 let c: f64 = kani::any();
254 let idx = argmax(&[a, b, c]);
255 assert!(idx < 3, "argmax index escaped the row");
256 }
257}