Skip to main content

oxirs_stream/ml/
classification.rs

1//! # Online classification models for streaming
2//!
3//! Two complementary models for binary and multi-class classification on
4//! streams:
5//!
6//! * [`OnlineLogisticClassifier`] — multinomial logistic regression with
7//!   per-class weight vectors trained via stochastic gradient descent on the
8//!   cross-entropy loss. Suitable when classes are roughly linearly separable
9//!   in feature space.
10//! * [`StreamingKnnClassifier`] — sliding-window k-nearest-neighbour
11//!   classifier that keeps the latest `window_size` labelled observations.
12//!   The implementation is exact (Euclidean distance, no approximation) and
13//!   appropriate for low-dimensional features where the working set fits in
14//!   memory.
15//!
16//! Both classifiers implement [`StreamClassifier`].
17
18use std::collections::HashMap;
19use std::collections::VecDeque;
20use std::sync::Arc;
21
22use parking_lot::RwLock;
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use tracing::debug;
26
27use scirs2_core::ndarray_ext::Array1;
28
29// ─── Errors ─────────────────────────────────────────────────────────────────
30
31/// Errors raised by the classification models.
32#[derive(Debug, Error)]
33pub enum ClassificationError {
34    /// Feature vector length did not match the model's configured dimension.
35    #[error("feature dimension mismatch: expected {expected}, got {actual}")]
36    DimensionMismatch { expected: usize, actual: usize },
37    /// The requested class label is outside the configured range.
38    #[error("class label {label} out of range (n_classes = {n_classes})")]
39    LabelOutOfRange { label: usize, n_classes: usize },
40    /// Model has not seen enough samples yet.
41    #[error("model not ready: only {observed} of {required} samples observed")]
42    NotReady { observed: usize, required: usize },
43    /// Configuration value out of range.
44    #[error("invalid config: {0}")]
45    InvalidConfig(String),
46}
47
48/// Convenience alias.
49pub type ClassificationResult<T> = std::result::Result<T, ClassificationError>;
50
51// ─── StreamClassifier trait ─────────────────────────────────────────────────
52
53/// Result of a single classification call.
54#[derive(Debug, Clone, PartialEq)]
55pub struct ClassPrediction {
56    /// Predicted class label (0-indexed).
57    pub label: usize,
58    /// Per-class scores; for probabilistic models these are normalised to a
59    /// proper distribution. For [`StreamingKnnClassifier`] they are vote counts
60    /// divided by `k` so still in `[0, 1]`.
61    pub scores: Vec<f64>,
62}
63
64/// Common interface for streaming classifiers.
65pub trait StreamClassifier: Send + Sync {
66    /// Number of input features.
67    fn n_features(&self) -> usize;
68    /// Number of classes.
69    fn n_classes(&self) -> usize;
70    /// Number of `(features, label)` samples observed so far.
71    fn n_observed(&self) -> u64;
72    /// Update the model with a single labelled observation.
73    fn observe(&self, features: &Array1<f64>, label: usize) -> ClassificationResult<()>;
74    /// Produce a class prediction.
75    fn predict(&self, features: &Array1<f64>) -> ClassificationResult<ClassPrediction>;
76}
77
78// ─── OnlineLogisticClassifier ───────────────────────────────────────────────
79
80/// Configuration for [`OnlineLogisticClassifier`].
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct LogisticConfig {
83    /// Number of input features.
84    pub n_features: usize,
85    /// Number of classes.
86    pub n_classes: usize,
87    /// SGD learning rate.
88    pub learning_rate: f64,
89    /// L2 regularisation strength (`0.0` disables).
90    pub l2: f64,
91    /// Min samples required before [`OnlineLogisticClassifier::predict`] returns.
92    pub min_samples: u64,
93}
94
95impl Default for LogisticConfig {
96    fn default() -> Self {
97        Self {
98            n_features: 4,
99            n_classes: 2,
100            learning_rate: 0.05,
101            l2: 0.0,
102            min_samples: 5,
103        }
104    }
105}
106
107impl LogisticConfig {
108    fn validate(&self) -> ClassificationResult<()> {
109        if self.n_features == 0 {
110            return Err(ClassificationError::InvalidConfig(
111                "n_features must be > 0".into(),
112            ));
113        }
114        if self.n_classes < 2 {
115            return Err(ClassificationError::InvalidConfig(
116                "n_classes must be >= 2".into(),
117            ));
118        }
119        if !self.learning_rate.is_finite() || self.learning_rate <= 0.0 {
120            return Err(ClassificationError::InvalidConfig(
121                "learning_rate must be positive".into(),
122            ));
123        }
124        if !self.l2.is_finite() || self.l2 < 0.0 {
125            return Err(ClassificationError::InvalidConfig(
126                "l2 must be non-negative".into(),
127            ));
128        }
129        Ok(())
130    }
131}
132
133/// Inner mutable state of [`OnlineLogisticClassifier`].
134struct LogisticState {
135    /// Shape `(n_classes, n_features)` flattened row-major.
136    weights: Vec<Array1<f64>>,
137    bias: Array1<f64>,
138    samples: u64,
139    last_loss: f64,
140}
141
142/// Multinomial logistic regression trained via online SGD.
143pub struct OnlineLogisticClassifier {
144    config: LogisticConfig,
145    state: Arc<RwLock<LogisticState>>,
146}
147
148impl std::fmt::Debug for OnlineLogisticClassifier {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        let st = self.state.read();
151        f.debug_struct("OnlineLogisticClassifier")
152            .field("config", &self.config)
153            .field("samples", &st.samples)
154            .field("last_loss", &st.last_loss)
155            .finish()
156    }
157}
158
159impl OnlineLogisticClassifier {
160    /// Build from config.
161    pub fn new(config: LogisticConfig) -> ClassificationResult<Self> {
162        config.validate()?;
163        let weights = (0..config.n_classes)
164            .map(|_| Array1::zeros(config.n_features))
165            .collect::<Vec<_>>();
166        let state = LogisticState {
167            weights,
168            bias: Array1::zeros(config.n_classes),
169            samples: 0,
170            last_loss: 0.0,
171        };
172        Ok(Self {
173            config,
174            state: Arc::new(RwLock::new(state)),
175        })
176    }
177
178    /// Last per-sample cross-entropy.
179    pub fn last_loss(&self) -> f64 {
180        self.state.read().last_loss
181    }
182
183    fn check_dim(&self, x: &Array1<f64>) -> ClassificationResult<()> {
184        if x.len() != self.config.n_features {
185            return Err(ClassificationError::DimensionMismatch {
186                expected: self.config.n_features,
187                actual: x.len(),
188            });
189        }
190        Ok(())
191    }
192
193    fn check_label(&self, label: usize) -> ClassificationResult<()> {
194        if label >= self.config.n_classes {
195            return Err(ClassificationError::LabelOutOfRange {
196                label,
197                n_classes: self.config.n_classes,
198            });
199        }
200        Ok(())
201    }
202
203    /// Forward pass: per-class score vector (logits → softmax).
204    fn forward(&self, x: &Array1<f64>) -> Array1<f64> {
205        let st = self.state.read();
206        let mut logits = Array1::zeros(self.config.n_classes);
207        for c in 0..self.config.n_classes {
208            let mut z = st.bias[c];
209            for i in 0..self.config.n_features {
210                z += st.weights[c][i] * x[i];
211            }
212            logits[c] = z;
213        }
214        softmax(&logits)
215    }
216}
217
218fn softmax(logits: &Array1<f64>) -> Array1<f64> {
219    let mut m = f64::NEG_INFINITY;
220    for v in logits.iter() {
221        if *v > m {
222            m = *v;
223        }
224    }
225    let mut sum = 0.0;
226    let mut out = Array1::zeros(logits.len());
227    for i in 0..logits.len() {
228        let e = (logits[i] - m).exp();
229        out[i] = e;
230        sum += e;
231    }
232    if sum > 0.0 {
233        for i in 0..out.len() {
234            out[i] /= sum;
235        }
236    }
237    out
238}
239
240fn argmax(scores: &Array1<f64>) -> usize {
241    let mut best = 0usize;
242    let mut best_score = f64::NEG_INFINITY;
243    for (i, v) in scores.iter().enumerate() {
244        if *v > best_score {
245            best_score = *v;
246            best = i;
247        }
248    }
249    best
250}
251
252impl StreamClassifier for OnlineLogisticClassifier {
253    fn n_features(&self) -> usize {
254        self.config.n_features
255    }
256
257    fn n_classes(&self) -> usize {
258        self.config.n_classes
259    }
260
261    fn n_observed(&self) -> u64 {
262        self.state.read().samples
263    }
264
265    fn observe(&self, features: &Array1<f64>, label: usize) -> ClassificationResult<()> {
266        self.check_dim(features)?;
267        self.check_label(label)?;
268        let probs = self.forward(features);
269
270        // Compute cross-entropy loss.
271        let p_label = probs[label].max(1e-12);
272        let loss = -p_label.ln();
273
274        let lr = self.config.learning_rate;
275        let l2 = self.config.l2;
276
277        let mut st = self.state.write();
278        for c in 0..self.config.n_classes {
279            let target = if c == label { 1.0 } else { 0.0 };
280            let err = probs[c] - target;
281            for i in 0..self.config.n_features {
282                let grad = err * features[i] + l2 * st.weights[c][i];
283                st.weights[c][i] -= lr * grad;
284            }
285            st.bias[c] -= lr * err;
286        }
287        st.samples += 1;
288        st.last_loss = loss;
289        Ok(())
290    }
291
292    fn predict(&self, features: &Array1<f64>) -> ClassificationResult<ClassPrediction> {
293        self.check_dim(features)?;
294        let observed = self.state.read().samples;
295        if observed < self.config.min_samples {
296            return Err(ClassificationError::NotReady {
297                observed: observed as usize,
298                required: self.config.min_samples as usize,
299            });
300        }
301        let probs = self.forward(features);
302        let label = argmax(&probs);
303        Ok(ClassPrediction {
304            label,
305            scores: probs.to_vec(),
306        })
307    }
308}
309
310// ─── StreamingKnnClassifier ────────────────────────────────────────────────
311
312/// Configuration for [`StreamingKnnClassifier`].
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct KnnConfig {
315    /// Number of input features.
316    pub n_features: usize,
317    /// Number of classes (used for score vector sizing).
318    pub n_classes: usize,
319    /// Number of neighbours queried per prediction.
320    pub k: usize,
321    /// Sliding window of the last N observations.
322    pub window_size: usize,
323    /// If true, weight votes inversely with distance.
324    pub distance_weighted: bool,
325    /// Minimum number of samples required before [`StreamingKnnClassifier::predict`] returns.
326    pub min_samples: u64,
327}
328
329impl Default for KnnConfig {
330    fn default() -> Self {
331        Self {
332            n_features: 4,
333            n_classes: 2,
334            k: 5,
335            window_size: 200,
336            distance_weighted: false,
337            min_samples: 5,
338        }
339    }
340}
341
342impl KnnConfig {
343    fn validate(&self) -> ClassificationResult<()> {
344        if self.n_features == 0 {
345            return Err(ClassificationError::InvalidConfig(
346                "n_features must be > 0".into(),
347            ));
348        }
349        if self.n_classes < 2 {
350            return Err(ClassificationError::InvalidConfig(
351                "n_classes must be >= 2".into(),
352            ));
353        }
354        if self.k == 0 || self.window_size == 0 {
355            return Err(ClassificationError::InvalidConfig(
356                "k and window_size must be > 0".into(),
357            ));
358        }
359        if self.k > self.window_size {
360            return Err(ClassificationError::InvalidConfig(
361                "k must be <= window_size".into(),
362            ));
363        }
364        Ok(())
365    }
366}
367
368#[derive(Debug, Clone)]
369struct LabeledPoint {
370    features: Array1<f64>,
371    label: usize,
372}
373
374struct KnnState {
375    window: VecDeque<LabeledPoint>,
376    samples: u64,
377}
378
379/// Sliding-window k-nearest-neighbour classifier.
380pub struct StreamingKnnClassifier {
381    config: KnnConfig,
382    state: Arc<RwLock<KnnState>>,
383}
384
385impl std::fmt::Debug for StreamingKnnClassifier {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        let st = self.state.read();
388        f.debug_struct("StreamingKnnClassifier")
389            .field("config", &self.config)
390            .field("samples", &st.samples)
391            .field("window_count", &st.window.len())
392            .finish()
393    }
394}
395
396impl StreamingKnnClassifier {
397    /// Build from config.
398    pub fn new(config: KnnConfig) -> ClassificationResult<Self> {
399        config.validate()?;
400        let state = KnnState {
401            window: VecDeque::with_capacity(config.window_size),
402            samples: 0,
403        };
404        Ok(Self {
405            config,
406            state: Arc::new(RwLock::new(state)),
407        })
408    }
409
410    /// Number of points currently held in the window.
411    pub fn window_count(&self) -> usize {
412        self.state.read().window.len()
413    }
414
415    fn check_dim(&self, x: &Array1<f64>) -> ClassificationResult<()> {
416        if x.len() != self.config.n_features {
417            return Err(ClassificationError::DimensionMismatch {
418                expected: self.config.n_features,
419                actual: x.len(),
420            });
421        }
422        Ok(())
423    }
424
425    fn check_label(&self, label: usize) -> ClassificationResult<()> {
426        if label >= self.config.n_classes {
427            return Err(ClassificationError::LabelOutOfRange {
428                label,
429                n_classes: self.config.n_classes,
430            });
431        }
432        Ok(())
433    }
434}
435
436fn euclid(a: &Array1<f64>, b: &Array1<f64>) -> f64 {
437    debug_assert_eq!(a.len(), b.len());
438    let mut s = 0.0;
439    for i in 0..a.len() {
440        let d = a[i] - b[i];
441        s += d * d;
442    }
443    s.sqrt()
444}
445
446impl StreamClassifier for StreamingKnnClassifier {
447    fn n_features(&self) -> usize {
448        self.config.n_features
449    }
450
451    fn n_classes(&self) -> usize {
452        self.config.n_classes
453    }
454
455    fn n_observed(&self) -> u64 {
456        self.state.read().samples
457    }
458
459    fn observe(&self, features: &Array1<f64>, label: usize) -> ClassificationResult<()> {
460        self.check_dim(features)?;
461        self.check_label(label)?;
462        let mut st = self.state.write();
463        if st.window.len() >= self.config.window_size {
464            st.window.pop_front();
465        }
466        st.window.push_back(LabeledPoint {
467            features: features.clone(),
468            label,
469        });
470        st.samples += 1;
471        Ok(())
472    }
473
474    fn predict(&self, features: &Array1<f64>) -> ClassificationResult<ClassPrediction> {
475        self.check_dim(features)?;
476        let st = self.state.read();
477        if st.samples < self.config.min_samples {
478            return Err(ClassificationError::NotReady {
479                observed: st.samples as usize,
480                required: self.config.min_samples as usize,
481            });
482        }
483
484        // Compute distances to every point in the window.
485        let mut candidates: Vec<(f64, usize)> = st
486            .window
487            .iter()
488            .map(|p| (euclid(&p.features, features), p.label))
489            .collect();
490        candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
491
492        let k = self.config.k.min(candidates.len());
493        let neighbours = &candidates[..k];
494
495        let mut votes: HashMap<usize, f64> = HashMap::new();
496        for (dist, label) in neighbours {
497            let weight = if self.config.distance_weighted {
498                1.0 / (dist + 1e-9)
499            } else {
500                1.0
501            };
502            *votes.entry(*label).or_insert(0.0) += weight;
503        }
504
505        let mut scores = vec![0.0; self.config.n_classes];
506        let total: f64 = votes.values().copied().sum();
507        for (label, weight) in &votes {
508            if *label < self.config.n_classes {
509                scores[*label] = if total > 0.0 { *weight / total } else { 0.0 };
510            }
511        }
512        let mut best = 0usize;
513        let mut best_score = -1.0;
514        for (i, s) in scores.iter().enumerate() {
515            if *s > best_score {
516                best_score = *s;
517                best = i;
518            }
519        }
520        debug!("knn predict: votes={votes:?}");
521        Ok(ClassPrediction {
522            label: best,
523            scores,
524        })
525    }
526}
527
528// ─── Tests ──────────────────────────────────────────────────────────────────
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn linsep_label(x: &Array1<f64>) -> usize {
535        // Class 1 if x0 + x1 >= 0, else class 0.
536        if x[0] + x[1] >= 0.0 {
537            1
538        } else {
539            0
540        }
541    }
542
543    #[test]
544    fn logistic_classifier_learns_linear_separation() {
545        let cfg = LogisticConfig {
546            n_features: 2,
547            n_classes: 2,
548            learning_rate: 0.1,
549            l2: 0.0,
550            min_samples: 5,
551        };
552        let model = OnlineLogisticClassifier::new(cfg).expect("ok");
553        for i in 0..2000 {
554            let x0 = ((i % 31) as f64) * 0.2 - 3.0;
555            let x1 = ((i % 23) as f64) * 0.3 - 3.0;
556            let x = Array1::from_vec(vec![x0, x1]);
557            let y = linsep_label(&x);
558            model.observe(&x, y).expect("observe");
559        }
560        let mut correct = 0;
561        let mut total = 0;
562        for i in 0..400 {
563            let x0 = ((i * 7) as f64).sin();
564            let x1 = ((i * 11) as f64).cos();
565            let x = Array1::from_vec(vec![x0, x1]);
566            let pred = model.predict(&x).expect("ready");
567            if pred.label == linsep_label(&x) {
568                correct += 1;
569            }
570            total += 1;
571        }
572        let accuracy = correct as f64 / total as f64;
573        assert!(
574            accuracy > 0.9,
575            "logistic regressor accuracy too low: {accuracy}"
576        );
577    }
578
579    #[test]
580    fn logistic_classifier_label_out_of_range() {
581        let cfg = LogisticConfig {
582            n_features: 2,
583            n_classes: 3,
584            ..Default::default()
585        };
586        let model = OnlineLogisticClassifier::new(cfg).expect("ok");
587        let x = Array1::from_vec(vec![1.0, 2.0]);
588        let err = model.observe(&x, 5).expect_err("should fail");
589        assert!(matches!(err, ClassificationError::LabelOutOfRange { .. }));
590    }
591
592    #[test]
593    fn logistic_classifier_dimension_mismatch() {
594        let cfg = LogisticConfig {
595            n_features: 3,
596            ..Default::default()
597        };
598        let model = OnlineLogisticClassifier::new(cfg).expect("ok");
599        let x = Array1::from_vec(vec![1.0, 2.0]);
600        let err = model.observe(&x, 0).expect_err("should fail");
601        assert!(matches!(err, ClassificationError::DimensionMismatch { .. }));
602    }
603
604    #[test]
605    fn logistic_classifier_invalid_config() {
606        let cfg = LogisticConfig {
607            n_classes: 1,
608            ..Default::default()
609        };
610        let err = OnlineLogisticClassifier::new(cfg).expect_err("should fail");
611        assert!(matches!(err, ClassificationError::InvalidConfig(_)));
612    }
613
614    fn three_cluster_label(x: &Array1<f64>) -> usize {
615        // Three radial clusters around (0,0), (5,5), (-5, 5).
616        let centres = [(0.0, 0.0), (5.0, 5.0), (-5.0, 5.0)];
617        let mut best = 0;
618        let mut best_d = f64::INFINITY;
619        for (i, c) in centres.iter().enumerate() {
620            let dx = x[0] - c.0;
621            let dy = x[1] - c.1;
622            let d = (dx * dx + dy * dy).sqrt();
623            if d < best_d {
624                best_d = d;
625                best = i;
626            }
627        }
628        best
629    }
630
631    #[test]
632    fn knn_classifier_three_clusters() {
633        let cfg = KnnConfig {
634            n_features: 2,
635            n_classes: 3,
636            k: 5,
637            window_size: 300,
638            distance_weighted: false,
639            min_samples: 30,
640        };
641        let model = StreamingKnnClassifier::new(cfg).expect("ok");
642        // Train: feed cluster centroids with deterministic offsets.
643        let centres = [(0.0, 0.0), (5.0, 5.0), (-5.0, 5.0)];
644        for i in 0..300 {
645            let cluster = i % 3;
646            let c = centres[cluster];
647            let dx = ((i / 3) as f64).sin() * 0.2;
648            let dy = ((i / 3) as f64).cos() * 0.2;
649            let x = Array1::from_vec(vec![c.0 + dx, c.1 + dy]);
650            model.observe(&x, cluster).expect("observe");
651        }
652
653        // Test points near each centre.
654        let probes = [
655            (Array1::from_vec(vec![0.1, -0.1]), 0),
656            (Array1::from_vec(vec![5.1, 4.9]), 1),
657            (Array1::from_vec(vec![-5.1, 5.2]), 2),
658        ];
659        for (probe, expected) in probes {
660            let pred = model.predict(&probe).expect("ready");
661            assert_eq!(
662                pred.label, expected,
663                "knn misclassified probe {probe:?} as {} (want {expected})",
664                pred.label
665            );
666            let total: f64 = pred.scores.iter().sum();
667            assert!(
668                (total - 1.0).abs() < 1e-6 || total == 0.0,
669                "scores should normalise to 1: {:?}",
670                pred.scores
671            );
672        }
673    }
674
675    #[test]
676    fn knn_distance_weighted_label_check() {
677        let cfg = KnnConfig {
678            n_features: 1,
679            n_classes: 2,
680            k: 3,
681            window_size: 5,
682            distance_weighted: true,
683            min_samples: 3,
684        };
685        let model = StreamingKnnClassifier::new(cfg).expect("ok");
686        // 1 close-by class-0 point + 2 far class-1 points.
687        model.observe(&Array1::from_vec(vec![0.0]), 0).expect("ok");
688        model.observe(&Array1::from_vec(vec![10.0]), 1).expect("ok");
689        model.observe(&Array1::from_vec(vec![11.0]), 1).expect("ok");
690
691        let probe = Array1::from_vec(vec![0.1]);
692        let pred = model.predict(&probe).expect("ready");
693        assert_eq!(
694            pred.label, 0,
695            "with distance-weighted votes the close class-0 should dominate"
696        );
697    }
698
699    #[test]
700    fn knn_invalid_config() {
701        let cfg = KnnConfig {
702            k: 10,
703            window_size: 5,
704            ..Default::default()
705        };
706        let err = StreamingKnnClassifier::new(cfg).expect_err("should fail");
707        assert!(matches!(err, ClassificationError::InvalidConfig(_)));
708    }
709
710    #[test]
711    fn knn_window_eviction() {
712        let cfg = KnnConfig {
713            n_features: 1,
714            n_classes: 2,
715            k: 3,
716            window_size: 3,
717            distance_weighted: false,
718            min_samples: 3,
719        };
720        let model = StreamingKnnClassifier::new(cfg).expect("ok");
721        for i in 0..100 {
722            let x = Array1::from_vec(vec![i as f64]);
723            model.observe(&x, (i % 2) as usize).expect("observe");
724        }
725        assert_eq!(model.window_count(), 3);
726    }
727}