1use 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#[derive(Debug, Error)]
33pub enum ClassificationError {
34 #[error("feature dimension mismatch: expected {expected}, got {actual}")]
36 DimensionMismatch { expected: usize, actual: usize },
37 #[error("class label {label} out of range (n_classes = {n_classes})")]
39 LabelOutOfRange { label: usize, n_classes: usize },
40 #[error("model not ready: only {observed} of {required} samples observed")]
42 NotReady { observed: usize, required: usize },
43 #[error("invalid config: {0}")]
45 InvalidConfig(String),
46}
47
48pub type ClassificationResult<T> = std::result::Result<T, ClassificationError>;
50
51#[derive(Debug, Clone, PartialEq)]
55pub struct ClassPrediction {
56 pub label: usize,
58 pub scores: Vec<f64>,
62}
63
64pub trait StreamClassifier: Send + Sync {
66 fn n_features(&self) -> usize;
68 fn n_classes(&self) -> usize;
70 fn n_observed(&self) -> u64;
72 fn observe(&self, features: &Array1<f64>, label: usize) -> ClassificationResult<()>;
74 fn predict(&self, features: &Array1<f64>) -> ClassificationResult<ClassPrediction>;
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct LogisticConfig {
83 pub n_features: usize,
85 pub n_classes: usize,
87 pub learning_rate: f64,
89 pub l2: f64,
91 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
133struct LogisticState {
135 weights: Vec<Array1<f64>>,
137 bias: Array1<f64>,
138 samples: u64,
139 last_loss: f64,
140}
141
142pub 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct KnnConfig {
315 pub n_features: usize,
317 pub n_classes: usize,
319 pub k: usize,
321 pub window_size: usize,
323 pub distance_weighted: bool,
325 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
379pub 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 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 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 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#[cfg(test)]
531mod tests {
532 use super::*;
533
534 fn linsep_label(x: &Array1<f64>) -> usize {
535 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 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 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 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 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}