ipfrs_tensorlogic/online_learner.rs
1//! Online / incremental learning algorithms for streaming data.
2//!
3//! Implements three production-grade online learning algorithms:
4//!
5//! * **Perceptron** — classic binary classifier; updates weights only on mispredictions.
6//! * **Passive-Aggressive (PA-I)** — margin-based update with a soft constraint
7//! (`C` parameter) that controls the trade-off between aggressiveness and
8//! passiveness.
9//! * **SGD with Momentum** — stochastic gradient descent with configurable
10//! momentum, learning rate, and L2 regularisation.
11//!
12//! All algorithms share a unified [`OnlineLearner`] interface that tracks
13//! running statistics (total updates, accuracy, average loss, weight norm).
14//!
15//! # Examples
16//!
17//! ```rust
18//! use ipfrs_tensorlogic::online_learner::{
19//! OnlineLearner, OnlineAlgorithm, OlLossFunction, TrainingSample,
20//! };
21//!
22//! let mut learner = OnlineLearner::new(
23//! OnlineAlgorithm::Perceptron,
24//! 2,
25//! OlLossFunction::Hinge,
26//! );
27//!
28//! let sample = TrainingSample { features: vec![1.0, 0.5], label: 1.0 };
29//! learner.update(&sample).expect("example: should succeed in docs");
30//! let class = learner.predict_class(&[1.0, 0.5]).expect("example: should succeed in docs");
31//! assert!(class == 1 || class == -1);
32//! ```
33
34use std::fmt;
35use thiserror::Error;
36
37// ---------------------------------------------------------------------------
38// Error type
39// ---------------------------------------------------------------------------
40
41/// Errors that can be raised by [`OnlineLearner`] operations.
42#[derive(Debug, Error, Clone, PartialEq)]
43pub enum LearnerError {
44 /// Feature vector dimensionality does not match the learner.
45 #[error("dimension mismatch: expected {expected}, got {got}")]
46 DimensionMismatch { expected: usize, got: usize },
47
48 /// An empty input (zero-length feature vector or empty sample slice) was
49 /// provided where non-empty input is required.
50 #[error("empty input")]
51 EmptyInput,
52
53 /// A label value was provided that is invalid for the chosen algorithm
54 /// (e.g. a value other than ±1.0 for binary classification).
55 #[error("invalid label: {label} — binary classifiers expect +1.0 or -1.0")]
56 InvalidLabel { label: f64 },
57}
58
59// ---------------------------------------------------------------------------
60// Core enumerations
61// ---------------------------------------------------------------------------
62
63/// Online learning algorithm selection.
64#[derive(Debug, Clone, PartialEq)]
65pub enum OnlineAlgorithm {
66 /// Classic Perceptron binary classifier.
67 ///
68 /// Update rule (on misprediction only):
69 /// ```text
70 /// w[i] += label * x[i]
71 /// bias += label
72 /// ```
73 Perceptron,
74
75 /// Passive-Aggressive PA-I update.
76 ///
77 /// ```text
78 /// loss = max(0, 1 - label * score)
79 /// tau = loss / (||x||² + 1 / (2 * C))
80 /// w[i] += tau * label * x[i]
81 /// bias += tau * label
82 /// ```
83 PassiveAggressive {
84 /// Aggressiveness parameter. Larger values → more aggressive updates.
85 c: f64,
86 },
87
88 /// Stochastic gradient descent with momentum and L2 regularisation.
89 ///
90 /// ```text
91 /// velocity[i] = momentum * velocity[i] - lr * (grad[i] + l2_reg * w[i])
92 /// w[i] += velocity[i]
93 /// bias -= lr * (-label)
94 /// ```
95 SgdMomentum {
96 /// Learning rate (step size).
97 lr: f64,
98 /// Momentum coefficient ∈ [0, 1).
99 momentum: f64,
100 /// L2 weight-decay coefficient.
101 l2_reg: f64,
102 },
103}
104
105/// Loss function used for computing per-sample losses and SGD gradients.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum OlLossFunction {
108 /// `max(0, 1 − label · score)`
109 Hinge,
110 /// `max(0, 1 − label · score)²`
111 SquaredHinge,
112 /// `ln(1 + exp(−label · score))` — numerically stable via log-sum-exp.
113 LogLoss,
114}
115
116impl fmt::Display for OlLossFunction {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::Hinge => write!(f, "Hinge"),
120 Self::SquaredHinge => write!(f, "SquaredHinge"),
121 Self::LogLoss => write!(f, "LogLoss"),
122 }
123 }
124}
125
126// ---------------------------------------------------------------------------
127// Training sample
128// ---------------------------------------------------------------------------
129
130/// A single labelled training example for online learning.
131///
132/// For binary classification the label **must** be `+1.0` or `−1.0`.
133/// For regression the label may be any finite `f64`.
134#[derive(Debug, Clone, PartialEq)]
135pub struct TrainingSample {
136 /// Input feature vector.
137 pub features: Vec<f64>,
138 /// Target label. Binary classifiers expect ±1.0.
139 pub label: f64,
140}
141
142impl TrainingSample {
143 /// Construct a new training sample.
144 pub fn new(features: Vec<f64>, label: f64) -> Self {
145 Self { features, label }
146 }
147
148 /// Return `true` if the label is a valid binary classification label (±1.0).
149 pub fn is_valid_binary_label(&self) -> bool {
150 (self.label - 1.0).abs() < f64::EPSILON || (self.label + 1.0).abs() < f64::EPSILON
151 }
152}
153
154// ---------------------------------------------------------------------------
155// Statistics
156// ---------------------------------------------------------------------------
157
158/// Running statistics tracked by [`OnlineLearner`] across all updates and
159/// predictions.
160#[derive(Debug, Clone, PartialEq)]
161pub struct OnlineLearnerStats {
162 /// Total number of `update()` calls performed.
163 pub total_updates: u64,
164 /// Number of `predict_class()` calls that returned the correct label.
165 pub correct_predictions: u64,
166 /// Total number of `predict_class()` calls.
167 pub total_predictions: u64,
168 /// Running average of per-update losses.
169 pub avg_loss: f64,
170 /// L2 norm of the weight vector at the time `stats()` was called.
171 pub weight_norm: f64,
172}
173
174impl Default for OnlineLearnerStats {
175 fn default() -> Self {
176 Self {
177 total_updates: 0,
178 correct_predictions: 0,
179 total_predictions: 0,
180 avg_loss: 0.0,
181 weight_norm: 0.0,
182 }
183 }
184}
185
186// ---------------------------------------------------------------------------
187// Internal running-average accumulator (Welford online algorithm)
188// ---------------------------------------------------------------------------
189
190#[derive(Debug, Clone, Default)]
191struct RunningMean {
192 count: u64,
193 mean: f64,
194}
195
196impl RunningMean {
197 fn update(&mut self, value: f64) {
198 self.count += 1;
199 let delta = value - self.mean;
200 self.mean += delta / self.count as f64;
201 }
202
203 fn value(&self) -> f64 {
204 self.mean
205 }
206}
207
208// ---------------------------------------------------------------------------
209// Main learner struct
210// ---------------------------------------------------------------------------
211
212/// Online / incremental learner supporting Perceptron, Passive-Aggressive, and
213/// SGD-with-Momentum algorithms.
214///
215/// The learner maintains a weight vector `w ∈ ℝᵈ` and a scalar `bias`, updated
216/// sample-by-sample via the selected [`OnlineAlgorithm`].
217#[derive(Debug, Clone)]
218pub struct OnlineLearner {
219 /// The update algorithm in use.
220 pub algorithm: OnlineAlgorithm,
221 /// Current weight vector.
222 pub weights: Vec<f64>,
223 /// Scalar bias term.
224 pub bias: f64,
225 /// Dimensionality (number of features).
226 pub dims: usize,
227 /// Loss function for computing per-sample losses.
228 pub loss_fn: OlLossFunction,
229 /// Velocity buffer for SGD-with-Momentum (zero for other algorithms).
230 pub velocity: Vec<f64>,
231
232 // Internal stats tracking
233 running_loss: RunningMean,
234 total_updates: u64,
235 correct_predictions: u64,
236 total_predictions: u64,
237}
238
239impl OnlineLearner {
240 // -----------------------------------------------------------------------
241 // Construction
242 // -----------------------------------------------------------------------
243
244 /// Create a new [`OnlineLearner`] with zero-initialised weights.
245 ///
246 /// # Arguments
247 ///
248 /// * `algorithm` — update rule to apply on each `update()` call.
249 /// * `dims` — feature dimensionality; all input vectors must have
250 /// exactly `dims` elements.
251 /// * `loss_fn` — loss function used for reporting and SGD gradient
252 /// computation.
253 ///
254 /// # Panics
255 ///
256 /// Does not panic; returns a well-formed `OnlineLearner` even for `dims == 0`.
257 pub fn new(algorithm: OnlineAlgorithm, dims: usize, loss_fn: OlLossFunction) -> Self {
258 Self {
259 algorithm,
260 weights: vec![0.0_f64; dims],
261 bias: 0.0,
262 dims,
263 loss_fn,
264 velocity: vec![0.0_f64; dims],
265 running_loss: RunningMean::default(),
266 total_updates: 0,
267 correct_predictions: 0,
268 total_predictions: 0,
269 }
270 }
271
272 // -----------------------------------------------------------------------
273 // Prediction
274 // -----------------------------------------------------------------------
275
276 /// Compute the raw decision score: `dot(weights, features) + bias`.
277 ///
278 /// # Errors
279 ///
280 /// Returns [`LearnerError::EmptyInput`] if `features` is empty when
281 /// `dims > 0`, or [`LearnerError::DimensionMismatch`] if
282 /// `features.len() != dims`.
283 pub fn predict(&self, features: &[f64]) -> Result<f64, LearnerError> {
284 self.check_dims(features)?;
285 Ok(dot(&self.weights, features) + self.bias)
286 }
287
288 /// Return the predicted class (`+1` or `−1`) for `features`.
289 ///
290 /// The class is the sign of [`predict`](Self::predict). A score of
291 /// exactly zero is classified as `+1`.
292 ///
293 /// This method also updates the internal prediction statistics.
294 ///
295 /// # Errors
296 ///
297 /// Propagates errors from [`predict`](Self::predict).
298 pub fn predict_class(&mut self, features: &[f64]) -> Result<i32, LearnerError> {
299 let score = self.predict(features)?;
300 self.total_predictions += 1;
301 Ok(if score >= 0.0 { 1 } else { -1 })
302 }
303
304 /// A non-mutating variant of [`predict_class`](Self::predict_class) that
305 /// does **not** update internal prediction statistics.
306 ///
307 /// Useful for evaluation loops where you want to call `accuracy()` later
308 /// without double-counting.
309 pub fn classify(&self, features: &[f64]) -> Result<i32, LearnerError> {
310 let score = self.predict(features)?;
311 Ok(if score >= 0.0 { 1 } else { -1 })
312 }
313
314 // -----------------------------------------------------------------------
315 // Loss computation
316 // -----------------------------------------------------------------------
317
318 /// Compute the loss for a given `(score, label)` pair using the learner's
319 /// configured [`OlLossFunction`].
320 ///
321 /// | Loss | Formula |
322 /// |---------------|------------------------------------------------|
323 /// | Hinge | `max(0, 1 − label · score)` |
324 /// | SquaredHinge | `max(0, 1 − label · score)²` |
325 /// | LogLoss | `ln(1 + exp(−label · score))` (stable) |
326 pub fn loss(&self, score: f64, label: f64) -> f64 {
327 compute_loss(self.loss_fn, score, label)
328 }
329
330 // -----------------------------------------------------------------------
331 // Online update
332 // -----------------------------------------------------------------------
333
334 /// Perform a single online update for `sample` and return the pre-update
335 /// loss.
336 ///
337 /// # Errors
338 ///
339 /// * [`LearnerError::EmptyInput`] — `sample.features` is empty but
340 /// `dims > 0`.
341 /// * [`LearnerError::DimensionMismatch`] — feature length ≠ `dims`.
342 /// * [`LearnerError::InvalidLabel`] — label is not ±1.0 for Perceptron or
343 /// Passive-Aggressive (binary classifiers).
344 pub fn update(&mut self, sample: &TrainingSample) -> Result<f64, LearnerError> {
345 self.check_dims(&sample.features)?;
346
347 // Binary classifiers require ±1.0 labels.
348 match &self.algorithm {
349 OnlineAlgorithm::Perceptron | OnlineAlgorithm::PassiveAggressive { .. } => {
350 if !is_binary_label(sample.label) {
351 return Err(LearnerError::InvalidLabel {
352 label: sample.label,
353 });
354 }
355 }
356 OnlineAlgorithm::SgdMomentum { .. } => {}
357 }
358
359 let score = dot(&self.weights, &sample.features) + self.bias;
360 let loss = compute_loss(self.loss_fn, score, sample.label);
361
362 // Clone algorithm to avoid borrow issues
363 let algo = self.algorithm.clone();
364 match algo {
365 OnlineAlgorithm::Perceptron => {
366 self.update_perceptron(sample.label, &sample.features, score);
367 }
368 OnlineAlgorithm::PassiveAggressive { c } => {
369 self.update_pa(sample.label, &sample.features, score, c);
370 }
371 OnlineAlgorithm::SgdMomentum {
372 lr,
373 momentum,
374 l2_reg,
375 } => {
376 self.update_sgd(sample.label, &sample.features, score, lr, momentum, l2_reg);
377 }
378 }
379
380 self.running_loss.update(loss);
381 self.total_updates += 1;
382 Ok(loss)
383 }
384
385 /// Perform online updates for a batch of samples, returning the per-sample
386 /// losses in the same order as `samples`.
387 ///
388 /// Equivalent to calling [`update`](Self::update) in sequence.
389 ///
390 /// # Errors
391 ///
392 /// Returns the first error encountered, if any.
393 pub fn batch_update(&mut self, samples: &[TrainingSample]) -> Result<Vec<f64>, LearnerError> {
394 if samples.is_empty() {
395 return Err(LearnerError::EmptyInput);
396 }
397 let mut losses = Vec::with_capacity(samples.len());
398 for sample in samples {
399 losses.push(self.update(sample)?);
400 }
401 Ok(losses)
402 }
403
404 // -----------------------------------------------------------------------
405 // Evaluation
406 // -----------------------------------------------------------------------
407
408 /// Compute the fraction of `samples` correctly classified without updating
409 /// weights.
410 ///
411 /// Classification is performed via [`classify`](Self::classify) so the
412 /// internal `total_predictions` counter is **not** incremented.
413 ///
414 /// # Errors
415 ///
416 /// * [`LearnerError::EmptyInput`] — `samples` is empty.
417 /// * Propagates dimension/label errors from `classify`.
418 pub fn accuracy(&self, samples: &[TrainingSample]) -> Result<f64, LearnerError> {
419 if samples.is_empty() {
420 return Err(LearnerError::EmptyInput);
421 }
422 let mut correct = 0usize;
423 for s in samples {
424 let predicted = self.classify(&s.features)?;
425 let expected = if s.label >= 0.0 { 1_i32 } else { -1_i32 };
426 if predicted == expected {
427 correct += 1;
428 }
429 }
430 Ok(correct as f64 / samples.len() as f64)
431 }
432
433 // -----------------------------------------------------------------------
434 // Maintenance
435 // -----------------------------------------------------------------------
436
437 /// Reset weights, bias, velocity, and all accumulated statistics to zero.
438 pub fn reset(&mut self) {
439 self.weights.fill(0.0);
440 self.bias = 0.0;
441 self.velocity.fill(0.0);
442 self.running_loss = RunningMean::default();
443 self.total_updates = 0;
444 self.correct_predictions = 0;
445 self.total_predictions = 0;
446 }
447
448 /// Compute the L2 norm of the weight vector: `√(Σ wᵢ²)`.
449 pub fn l2_norm(&self) -> f64 {
450 self.weights.iter().map(|w| w * w).sum::<f64>().sqrt()
451 }
452
453 /// Snapshot current training statistics.
454 pub fn stats(&self) -> OnlineLearnerStats {
455 OnlineLearnerStats {
456 total_updates: self.total_updates,
457 correct_predictions: self.correct_predictions,
458 total_predictions: self.total_predictions,
459 avg_loss: self.running_loss.value(),
460 weight_norm: self.l2_norm(),
461 }
462 }
463
464 // -----------------------------------------------------------------------
465 // Private update helpers
466 // -----------------------------------------------------------------------
467
468 fn update_perceptron(&mut self, label: f64, features: &[f64], score: f64) {
469 // Only update on misprediction: label * score ≤ 0
470 if label * score <= 0.0 {
471 for (w, &x) in self.weights.iter_mut().zip(features.iter()) {
472 *w += label * x;
473 }
474 self.bias += label;
475 }
476 }
477
478 fn update_pa(&mut self, label: f64, features: &[f64], score: f64, c: f64) {
479 // Hinge loss (always used for PA update regardless of loss_fn setting)
480 let margin = label * score;
481 let hinge = (1.0 - margin).max(0.0);
482
483 if hinge == 0.0 {
484 // Already in the margin — passive (no update)
485 return;
486 }
487
488 let sq_norm: f64 = features.iter().map(|x| x * x).sum();
489 // PA-I: tau = hinge / (||x||² + 1/(2C))
490 let denom = sq_norm + 1.0 / (2.0 * c);
491 let tau = hinge / denom;
492
493 for (w, &x) in self.weights.iter_mut().zip(features.iter()) {
494 *w += tau * label * x;
495 }
496 self.bias += tau * label;
497 }
498
499 fn update_sgd(
500 &mut self,
501 label: f64,
502 features: &[f64],
503 score: f64,
504 lr: f64,
505 momentum: f64,
506 l2_reg: f64,
507 ) {
508 // Subgradient of hinge loss w.r.t. score:
509 // if margin < 1 → -label (we're inside the margin)
510 // if margin >= 1 → 0.0 (correct & outside margin — no grad)
511 // For LogLoss, use the logistic gradient: -label * sigmoid(-label*score)
512 let grad_score = match self.loss_fn {
513 OlLossFunction::Hinge | OlLossFunction::SquaredHinge => {
514 let margin = label * score;
515 if margin < 1.0 {
516 -label
517 } else {
518 0.0
519 }
520 }
521 OlLossFunction::LogLoss => {
522 // d/d_score ln(1 + exp(-y*s)) = -y * sigma(-y*s)
523 let neg_margin = -(label * score);
524 let sigma = stable_sigmoid(neg_margin);
525 -label * sigma
526 }
527 };
528
529 // Update weight velocity and weights
530 for (i, &xi) in features.iter().enumerate().take(self.dims) {
531 let grad_w = grad_score * xi + l2_reg * self.weights[i];
532 self.velocity[i] = momentum * self.velocity[i] - lr * grad_w;
533 self.weights[i] += self.velocity[i];
534 }
535
536 // Bias does not get L2 regularisation (standard practice)
537 self.bias -= lr * grad_score;
538 }
539
540 // -----------------------------------------------------------------------
541 // Validation helper
542 // -----------------------------------------------------------------------
543
544 fn check_dims(&self, features: &[f64]) -> Result<(), LearnerError> {
545 if self.dims == 0 && features.is_empty() {
546 return Ok(());
547 }
548 if features.is_empty() {
549 return Err(LearnerError::EmptyInput);
550 }
551 if features.len() != self.dims {
552 return Err(LearnerError::DimensionMismatch {
553 expected: self.dims,
554 got: features.len(),
555 });
556 }
557 Ok(())
558 }
559
560 // -----------------------------------------------------------------------
561 // Additional evaluation helpers
562 // -----------------------------------------------------------------------
563
564 /// Compute the average loss over a slice of samples without updating weights.
565 ///
566 /// # Errors
567 ///
568 /// Returns [`LearnerError::EmptyInput`] if `samples` is empty, or
569 /// propagates dimension errors.
570 pub fn average_loss(&self, samples: &[TrainingSample]) -> Result<f64, LearnerError> {
571 if samples.is_empty() {
572 return Err(LearnerError::EmptyInput);
573 }
574 let total: f64 = samples
575 .iter()
576 .map(|s| {
577 let score = dot(&self.weights, &s.features) + self.bias;
578 compute_loss(self.loss_fn, score, s.label)
579 })
580 .sum();
581 Ok(total / samples.len() as f64)
582 }
583
584 /// Compute per-sample losses over `samples` without updating weights.
585 ///
586 /// # Errors
587 ///
588 /// Returns [`LearnerError::EmptyInput`] if `samples` is empty.
589 pub fn evaluate_losses(&self, samples: &[TrainingSample]) -> Result<Vec<f64>, LearnerError> {
590 if samples.is_empty() {
591 return Err(LearnerError::EmptyInput);
592 }
593 samples
594 .iter()
595 .map(|s| {
596 self.check_dims(&s.features)?;
597 let score = dot(&self.weights, &s.features) + self.bias;
598 Ok(compute_loss(self.loss_fn, score, s.label))
599 })
600 .collect()
601 }
602
603 /// Record a correct/incorrect prediction result into the running stats.
604 ///
605 /// This is used internally when `predict_class` is called. Exposed
606 /// publicly for external evaluation loops that use `classify()` and wish
607 /// to manually feed outcomes back.
608 pub fn record_prediction(&mut self, was_correct: bool) {
609 self.total_predictions += 1;
610 if was_correct {
611 self.correct_predictions += 1;
612 }
613 }
614
615 /// Return a reference to the current weight vector.
616 pub fn weights(&self) -> &[f64] {
617 &self.weights
618 }
619
620 /// Return the current bias value.
621 pub fn bias(&self) -> f64 {
622 self.bias
623 }
624
625 /// Return the number of features this learner was constructed for.
626 pub fn dims(&self) -> usize {
627 self.dims
628 }
629}
630
631// ---------------------------------------------------------------------------
632// Free-function helpers (module-private)
633// ---------------------------------------------------------------------------
634
635/// Dot product of two equal-length slices.
636fn dot(a: &[f64], b: &[f64]) -> f64 {
637 a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
638}
639
640/// Numerically stable sigmoid: σ(x) = 1/(1 + exp(-x)).
641///
642/// Uses the standard trick of branching on the sign of x to avoid overflow.
643fn stable_sigmoid(x: f64) -> f64 {
644 if x >= 0.0 {
645 let e = (-x).exp();
646 1.0 / (1.0 + e)
647 } else {
648 let e = x.exp();
649 e / (1.0 + e)
650 }
651}
652
653/// Numerically stable log-sigmoid loss: ln(1 + exp(-margin)).
654///
655/// Uses log-sum-exp trick for numerical stability.
656fn log_loss_stable(margin: f64) -> f64 {
657 // ln(1 + exp(-margin))
658 if margin >= 0.0 {
659 // margin >= 0 → exp(-margin) ≤ 1 → no overflow
660 (-margin).exp().ln_1p()
661 } else {
662 // margin < 0 → -margin > 0 → exp(-margin) can overflow
663 // Use: ln(1 + exp(-margin)) = -margin + ln(1 + exp(margin))
664 -margin + margin.exp().ln_1p()
665 }
666}
667
668/// Compute loss for the given function variant.
669fn compute_loss(loss_fn: OlLossFunction, score: f64, label: f64) -> f64 {
670 let margin = label * score;
671 match loss_fn {
672 OlLossFunction::Hinge => (1.0 - margin).max(0.0),
673 OlLossFunction::SquaredHinge => {
674 let h = (1.0 - margin).max(0.0);
675 h * h
676 }
677 OlLossFunction::LogLoss => log_loss_stable(margin),
678 }
679}
680
681/// Return `true` iff `label` is ±1.0 (up to floating-point precision).
682fn is_binary_label(label: f64) -> bool {
683 (label - 1.0).abs() < 1e-9 || (label + 1.0).abs() < 1e-9
684}
685
686// ---------------------------------------------------------------------------
687// Tests
688// ---------------------------------------------------------------------------
689
690#[cfg(test)]
691mod tests {
692 use super::{
693 compute_loss, dot, is_binary_label, log_loss_stable, stable_sigmoid, LearnerError,
694 OlLossFunction, OnlineAlgorithm, OnlineLearner, TrainingSample,
695 };
696
697 // -----------------------------------------------------------------------
698 // Helper builders
699 // -----------------------------------------------------------------------
700
701 fn perceptron(dims: usize) -> OnlineLearner {
702 OnlineLearner::new(OnlineAlgorithm::Perceptron, dims, OlLossFunction::Hinge)
703 }
704
705 fn pa(dims: usize, c: f64) -> OnlineLearner {
706 OnlineLearner::new(
707 OnlineAlgorithm::PassiveAggressive { c },
708 dims,
709 OlLossFunction::Hinge,
710 )
711 }
712
713 fn sgd(dims: usize, lr: f64, momentum: f64, l2_reg: f64) -> OnlineLearner {
714 OnlineLearner::new(
715 OnlineAlgorithm::SgdMomentum {
716 lr,
717 momentum,
718 l2_reg,
719 },
720 dims,
721 OlLossFunction::Hinge,
722 )
723 }
724
725 fn sample(features: Vec<f64>, label: f64) -> TrainingSample {
726 TrainingSample::new(features, label)
727 }
728
729 // -----------------------------------------------------------------------
730 // Test 1: construction initialises to zero
731 // -----------------------------------------------------------------------
732 #[test]
733 fn test_construction_zero_init() {
734 let learner = perceptron(4);
735 assert_eq!(learner.dims(), 4);
736 assert_eq!(learner.bias(), 0.0);
737 assert!(learner.weights().iter().all(|&w| w == 0.0));
738 assert!(learner.velocity.iter().all(|&v| v == 0.0));
739 }
740
741 // -----------------------------------------------------------------------
742 // Test 2: predict on zero weights returns bias (0)
743 // -----------------------------------------------------------------------
744 #[test]
745 fn test_predict_zero_weights() {
746 let learner = perceptron(3);
747 let score = learner
748 .predict(&[1.0, 2.0, 3.0])
749 .expect("test: should succeed");
750 assert_eq!(score, 0.0);
751 }
752
753 // -----------------------------------------------------------------------
754 // Test 3: dimension mismatch error
755 // -----------------------------------------------------------------------
756 #[test]
757 fn test_dimension_mismatch() {
758 let learner = perceptron(3);
759 let err = learner.predict(&[1.0, 2.0]).unwrap_err();
760 assert!(matches!(
761 err,
762 LearnerError::DimensionMismatch {
763 expected: 3,
764 got: 2
765 }
766 ));
767 }
768
769 // -----------------------------------------------------------------------
770 // Test 4: empty input error
771 // -----------------------------------------------------------------------
772 #[test]
773 fn test_empty_input() {
774 let learner = perceptron(3);
775 let err = learner.predict(&[]).unwrap_err();
776 assert_eq!(err, LearnerError::EmptyInput);
777 }
778
779 // -----------------------------------------------------------------------
780 // Test 5: invalid label for perceptron
781 // -----------------------------------------------------------------------
782 #[test]
783 fn test_invalid_label_perceptron() {
784 let mut learner = perceptron(2);
785 let s = sample(vec![1.0, 0.0], 0.5);
786 let err = learner.update(&s).unwrap_err();
787 assert!(matches!(err, LearnerError::InvalidLabel { .. }));
788 }
789
790 // -----------------------------------------------------------------------
791 // Test 6: invalid label for PA
792 // -----------------------------------------------------------------------
793 #[test]
794 fn test_invalid_label_pa() {
795 let mut learner = pa(2, 1.0);
796 let s = sample(vec![1.0, 0.0], 0.0);
797 let err = learner.update(&s).unwrap_err();
798 assert!(matches!(err, LearnerError::InvalidLabel { .. }));
799 }
800
801 // -----------------------------------------------------------------------
802 // Test 7: SGD accepts non-binary labels
803 // -----------------------------------------------------------------------
804 #[test]
805 fn test_sgd_non_binary_label() {
806 let mut learner = sgd(2, 0.1, 0.9, 0.0);
807 let s = sample(vec![1.0, 0.5], 2.5);
808 // Should not error
809 learner.update(&s).expect("test: TD update should succeed");
810 }
811
812 // -----------------------------------------------------------------------
813 // Test 8: Perceptron updates on misprediction
814 // -----------------------------------------------------------------------
815 #[test]
816 fn test_perceptron_updates_on_misprediction() {
817 let mut learner = perceptron(2);
818 // Zero weights → score=0 → label*score=0 ≤ 0 → misprediction for label=1
819 let s = sample(vec![1.0, 1.0], 1.0);
820 learner.update(&s).expect("test: TD update should succeed");
821 // Weights should be updated: w += label * x → [1, 1]
822 assert_eq!(learner.weights()[0], 1.0);
823 assert_eq!(learner.weights()[1], 1.0);
824 assert_eq!(learner.bias(), 1.0);
825 }
826
827 // -----------------------------------------------------------------------
828 // Test 9: Perceptron no update when correctly classified
829 // -----------------------------------------------------------------------
830 #[test]
831 fn test_perceptron_no_update_correct() {
832 let mut learner = perceptron(2);
833 // Give it correct weights first
834 learner.weights[0] = 2.0;
835 learner.bias = 1.0;
836 // score = 2.0 * 1.0 + 1.0 = 3.0 → label*score = 3 > 0 → correct
837 let s = sample(vec![1.0, 0.0], 1.0);
838 learner.update(&s).expect("test: TD update should succeed");
839 assert_eq!(learner.weights()[0], 2.0); // unchanged
840 assert_eq!(learner.bias(), 1.0); // unchanged
841 }
842
843 // -----------------------------------------------------------------------
844 // Test 10: Perceptron converges on linearly separable data
845 // -----------------------------------------------------------------------
846 #[test]
847 fn test_perceptron_convergence() {
848 let mut learner = perceptron(2);
849 let positives: Vec<_> = (0..5)
850 .map(|i| sample(vec![i as f64 + 1.0, 0.5], 1.0))
851 .collect();
852 let negatives: Vec<_> = (0..5)
853 .map(|i| sample(vec![-(i as f64 + 1.0), -0.5], -1.0))
854 .collect();
855
856 let mut all: Vec<TrainingSample> = Vec::new();
857 all.extend(positives);
858 all.extend(negatives);
859
860 for _ in 0..20 {
861 for s in &all {
862 let _ = learner.update(s);
863 }
864 }
865 let acc = learner.accuracy(&all).expect("test: should succeed");
866 assert!(acc > 0.9, "Expected accuracy > 0.9, got {acc}");
867 }
868
869 // -----------------------------------------------------------------------
870 // Test 11: PA-I update reduces loss on positive example
871 // -----------------------------------------------------------------------
872 #[test]
873 fn test_pa_update_reduces_loss() {
874 let mut learner = pa(2, 1.0);
875 let s = sample(vec![1.0, 0.0], 1.0);
876 let pre_loss = learner.update(&s).expect("test: TD update should succeed");
877 let post_score = learner.predict(&s.features).expect("test: should succeed");
878 let post_loss = compute_loss(OlLossFunction::Hinge, post_score, 1.0);
879 // Loss should decrease or stay zero
880 assert!(post_loss <= pre_loss + 1e-10);
881 }
882
883 // -----------------------------------------------------------------------
884 // Test 12: PA-I passive on already correct prediction
885 // -----------------------------------------------------------------------
886 #[test]
887 fn test_pa_passive_when_correct() {
888 let mut learner = pa(2, 1.0);
889 // Set large weights so sample is correctly classified with large margin
890 learner.weights[0] = 10.0;
891 let s = sample(vec![1.0, 0.0], 1.0); // score = 10 → margin = 10 > 1
892 let w_before = learner.weights()[0];
893 learner.update(&s).expect("test: TD update should succeed");
894 assert_eq!(learner.weights()[0], w_before); // no update
895 }
896
897 // -----------------------------------------------------------------------
898 // Test 13: PA-I tau computation is correct
899 // -----------------------------------------------------------------------
900 #[test]
901 fn test_pa_tau_formula() {
902 let mut learner = pa(1, 1.0);
903 // x = [1.0], label = 1.0, initial score = 0
904 // loss = max(0, 1 - 1*0) = 1
905 // ||x||^2 = 1
906 // tau = 1 / (1 + 1/(2*1)) = 1 / 1.5 = 2/3
907 let s = sample(vec![1.0], 1.0);
908 learner.update(&s).expect("test: TD update should succeed");
909 let expected = 2.0 / 3.0;
910 assert!((learner.weights()[0] - expected).abs() < 1e-10);
911 }
912
913 // -----------------------------------------------------------------------
914 // Test 14: SGD momentum velocity accumulates
915 // -----------------------------------------------------------------------
916 #[test]
917 fn test_sgd_velocity_accumulates() {
918 let mut learner = sgd(2, 0.1, 0.9, 0.0);
919 let s = sample(vec![1.0, 1.0], 1.0);
920 learner.update(&s).expect("test: TD update should succeed");
921 // velocity should be non-zero after first update
922 let v_sum: f64 = learner.velocity.iter().sum();
923 assert_ne!(v_sum, 0.0);
924 }
925
926 // -----------------------------------------------------------------------
927 // Test 15: SGD L2 regularisation shrinks weights
928 // -----------------------------------------------------------------------
929 #[test]
930 fn test_sgd_l2_shrinks_weights() {
931 let mut learner = OnlineLearner::new(
932 OnlineAlgorithm::SgdMomentum {
933 lr: 0.01,
934 momentum: 0.0,
935 l2_reg: 0.1,
936 },
937 2,
938 OlLossFunction::Hinge,
939 );
940 // Give it some weights
941 learner.weights[0] = 5.0;
942 learner.weights[1] = 5.0;
943
944 // Correctly classified sample (no gradient from loss, only L2)
945 // score = 5.0 * 0.0 = 0 → loss grad = -label = -1 (in margin)
946 // But let's use large weights so the score will be large enough
947 learner.weights[0] = 5.0;
948 learner.weights[1] = 0.0;
949 // score = 5.0*1.0 + 0.0*0.0 = 5.0, margin = 5 > 1 → grad_score = 0
950 // Only L2 acts: grad_w = l2_reg * w[0] = 0.1 * 5 = 0.5
951 // velocity = 0 - 0.01 * 0.5 = -0.005
952 // w[0] = 5.0 - 0.005 = 4.995
953 let s = sample(vec![1.0, 0.0], 1.0);
954 learner.update(&s).expect("test: TD update should succeed");
955 assert!(learner.weights()[0] < 5.0);
956 }
957
958 // -----------------------------------------------------------------------
959 // Test 16: predict_class returns +1 or -1
960 // -----------------------------------------------------------------------
961 #[test]
962 fn test_predict_class_values() {
963 let mut learner = perceptron(2);
964 learner.weights[0] = 1.0;
965 let c1 = learner
966 .predict_class(&[1.0, 0.0])
967 .expect("test: should succeed");
968 let c2 = learner
969 .predict_class(&[-1.0, 0.0])
970 .expect("test: should succeed");
971 assert_eq!(c1, 1);
972 assert_eq!(c2, -1);
973 }
974
975 // -----------------------------------------------------------------------
976 // Test 17: predict_class updates total_predictions
977 // -----------------------------------------------------------------------
978 #[test]
979 fn test_predict_class_updates_stats() {
980 let mut learner = perceptron(2);
981 learner
982 .predict_class(&[1.0, 0.0])
983 .expect("test: should succeed");
984 learner
985 .predict_class(&[1.0, 0.0])
986 .expect("test: should succeed");
987 assert_eq!(learner.stats().total_predictions, 2);
988 }
989
990 // -----------------------------------------------------------------------
991 // Test 18: batch_update returns per-sample losses
992 // -----------------------------------------------------------------------
993 #[test]
994 fn test_batch_update_returns_losses() {
995 let mut learner = perceptron(2);
996 let samples = vec![sample(vec![1.0, 0.0], 1.0), sample(vec![0.0, 1.0], -1.0)];
997 let losses = learner
998 .batch_update(&samples)
999 .expect("test: should succeed");
1000 assert_eq!(losses.len(), 2);
1001 assert!(losses.iter().all(|&l| l >= 0.0));
1002 }
1003
1004 // -----------------------------------------------------------------------
1005 // Test 19: batch_update on empty slice returns EmptyInput
1006 // -----------------------------------------------------------------------
1007 #[test]
1008 fn test_batch_update_empty() {
1009 let mut learner = perceptron(2);
1010 let err = learner.batch_update(&[]).unwrap_err();
1011 assert_eq!(err, LearnerError::EmptyInput);
1012 }
1013
1014 // -----------------------------------------------------------------------
1015 // Test 20: accuracy on perfectly learned data is 1.0
1016 // -----------------------------------------------------------------------
1017 #[test]
1018 fn test_accuracy_perfect() {
1019 let mut learner = perceptron(1);
1020 let samples = vec![sample(vec![3.0], 1.0), sample(vec![-3.0], -1.0)];
1021 // Train multiple epochs
1022 for _ in 0..10 {
1023 for s in &samples {
1024 let _ = learner.update(s);
1025 }
1026 }
1027 let acc = learner.accuracy(&samples).expect("test: should succeed");
1028 assert_eq!(acc, 1.0);
1029 }
1030
1031 // -----------------------------------------------------------------------
1032 // Test 21: accuracy on empty returns EmptyInput
1033 // -----------------------------------------------------------------------
1034 #[test]
1035 fn test_accuracy_empty() {
1036 let learner = perceptron(2);
1037 let err = learner.accuracy(&[]).unwrap_err();
1038 assert_eq!(err, LearnerError::EmptyInput);
1039 }
1040
1041 // -----------------------------------------------------------------------
1042 // Test 22: reset zeroes everything
1043 // -----------------------------------------------------------------------
1044 #[test]
1045 fn test_reset() {
1046 let mut learner = perceptron(3);
1047 let s = sample(vec![1.0, 1.0, 1.0], 1.0);
1048 learner.update(&s).expect("test: TD update should succeed");
1049 learner.reset();
1050 assert!(learner.weights().iter().all(|&w| w == 0.0));
1051 assert_eq!(learner.bias(), 0.0);
1052 assert_eq!(learner.stats().total_updates, 0);
1053 assert_eq!(learner.stats().avg_loss, 0.0);
1054 }
1055
1056 // -----------------------------------------------------------------------
1057 // Test 23: l2_norm of zero vector is 0
1058 // -----------------------------------------------------------------------
1059 #[test]
1060 fn test_l2_norm_zero() {
1061 let learner = perceptron(4);
1062 assert_eq!(learner.l2_norm(), 0.0);
1063 }
1064
1065 // -----------------------------------------------------------------------
1066 // Test 24: l2_norm computation is correct
1067 // -----------------------------------------------------------------------
1068 #[test]
1069 fn test_l2_norm_value() {
1070 let mut learner = perceptron(2);
1071 learner.weights[0] = 3.0;
1072 learner.weights[1] = 4.0;
1073 assert!((learner.l2_norm() - 5.0).abs() < 1e-10);
1074 }
1075
1076 // -----------------------------------------------------------------------
1077 // Test 25: stats() reports correct total_updates
1078 // -----------------------------------------------------------------------
1079 #[test]
1080 fn test_stats_total_updates() {
1081 let mut learner = perceptron(2);
1082 for _ in 0..5 {
1083 learner
1084 .update(&sample(vec![1.0, 0.0], 1.0))
1085 .expect("test: should succeed");
1086 }
1087 assert_eq!(learner.stats().total_updates, 5);
1088 }
1089
1090 // -----------------------------------------------------------------------
1091 // Test 26: avg_loss increases on hard examples
1092 // -----------------------------------------------------------------------
1093 #[test]
1094 fn test_stats_avg_loss_non_negative() {
1095 let mut learner = perceptron(2);
1096 let samples = vec![sample(vec![1.0, 0.0], 1.0), sample(vec![-1.0, 0.0], -1.0)];
1097 let _ = learner
1098 .batch_update(&samples)
1099 .expect("test: should succeed");
1100 assert!(learner.stats().avg_loss >= 0.0);
1101 }
1102
1103 // -----------------------------------------------------------------------
1104 // Test 27: Hinge loss computation
1105 // -----------------------------------------------------------------------
1106 #[test]
1107 fn test_hinge_loss() {
1108 // margin = 1 → loss = 0
1109 assert_eq!(compute_loss(OlLossFunction::Hinge, 1.0, 1.0), 0.0);
1110 // margin = 0.5 → loss = 0.5
1111 assert!((compute_loss(OlLossFunction::Hinge, 0.5, 1.0) - 0.5).abs() < 1e-10);
1112 // margin = -1 → loss = 2
1113 assert!((compute_loss(OlLossFunction::Hinge, -1.0, 1.0) - 2.0).abs() < 1e-10);
1114 }
1115
1116 // -----------------------------------------------------------------------
1117 // Test 28: SquaredHinge loss computation
1118 // -----------------------------------------------------------------------
1119 #[test]
1120 fn test_squared_hinge_loss() {
1121 // margin = 1 → loss = 0
1122 assert_eq!(compute_loss(OlLossFunction::SquaredHinge, 1.0, 1.0), 0.0);
1123 // margin = 0.5 → hinge = 0.5, loss = 0.25
1124 assert!((compute_loss(OlLossFunction::SquaredHinge, 0.5, 1.0) - 0.25).abs() < 1e-10);
1125 // margin = -1 → hinge = 2, loss = 4
1126 assert!((compute_loss(OlLossFunction::SquaredHinge, -1.0, 1.0) - 4.0).abs() < 1e-10);
1127 }
1128
1129 // -----------------------------------------------------------------------
1130 // Test 29: LogLoss computation and numerical stability
1131 // -----------------------------------------------------------------------
1132 #[test]
1133 fn test_log_loss_stability() {
1134 // At score=0, margin=0 → ln(2) ≈ 0.693
1135 let l = compute_loss(OlLossFunction::LogLoss, 0.0, 1.0);
1136 assert!((l - std::f64::consts::LN_2).abs() < 1e-10);
1137
1138 // Large positive margin → very small loss
1139 let l_large = compute_loss(OlLossFunction::LogLoss, 100.0, 1.0);
1140 assert!(l_large < 1e-10);
1141
1142 // Large negative margin → approximately equal to |margin|
1143 let l_neg = compute_loss(OlLossFunction::LogLoss, -100.0, 1.0);
1144 assert!((l_neg - 100.0).abs() < 1.0);
1145
1146 // Always non-negative
1147 for s in [-10.0_f64, -1.0, 0.0, 1.0, 10.0] {
1148 for y in [-1.0_f64, 1.0] {
1149 assert!(compute_loss(OlLossFunction::LogLoss, s, y) >= 0.0);
1150 }
1151 }
1152 }
1153
1154 // -----------------------------------------------------------------------
1155 // Test 30: stable_sigmoid is in (0,1) and symmetric
1156 // -----------------------------------------------------------------------
1157 #[test]
1158 fn test_stable_sigmoid() {
1159 assert!((stable_sigmoid(0.0) - 0.5).abs() < 1e-10);
1160 assert!(stable_sigmoid(100.0) > 0.999);
1161 assert!(stable_sigmoid(-100.0) < 0.001);
1162 // Symmetry: sigma(x) = 1 - sigma(-x)
1163 for x in [-5.0_f64, -1.0, 0.0, 1.0, 5.0] {
1164 assert!((stable_sigmoid(x) + stable_sigmoid(-x) - 1.0).abs() < 1e-12);
1165 }
1166 }
1167
1168 // -----------------------------------------------------------------------
1169 // Test 31: log_loss_stable equals ln(2) at margin=0
1170 // -----------------------------------------------------------------------
1171 #[test]
1172 fn test_log_loss_stable_fn() {
1173 let at_zero = log_loss_stable(0.0);
1174 assert!((at_zero - std::f64::consts::LN_2).abs() < 1e-12);
1175 // Positive margin → decreasing loss
1176 assert!(log_loss_stable(1.0) < log_loss_stable(0.0));
1177 assert!(log_loss_stable(5.0) < log_loss_stable(1.0));
1178 }
1179
1180 // -----------------------------------------------------------------------
1181 // Test 32: dot product correctness
1182 // -----------------------------------------------------------------------
1183 #[test]
1184 fn test_dot() {
1185 assert_eq!(dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]), 32.0);
1186 assert_eq!(dot(&[], &[]), 0.0);
1187 }
1188
1189 // -----------------------------------------------------------------------
1190 // Test 33: is_binary_label helper
1191 // -----------------------------------------------------------------------
1192 #[test]
1193 fn test_is_binary_label() {
1194 assert!(is_binary_label(1.0));
1195 assert!(is_binary_label(-1.0));
1196 assert!(!is_binary_label(0.0));
1197 assert!(!is_binary_label(2.0));
1198 assert!(!is_binary_label(0.5));
1199 }
1200
1201 // -----------------------------------------------------------------------
1202 // Test 34: TrainingSample::is_valid_binary_label
1203 // -----------------------------------------------------------------------
1204 #[test]
1205 fn test_training_sample_valid_binary_label() {
1206 let pos = sample(vec![1.0], 1.0);
1207 let neg = sample(vec![1.0], -1.0);
1208 let bad = sample(vec![1.0], 0.0);
1209 assert!(pos.is_valid_binary_label());
1210 assert!(neg.is_valid_binary_label());
1211 assert!(!bad.is_valid_binary_label());
1212 }
1213
1214 // -----------------------------------------------------------------------
1215 // Test 35: classify does not modify prediction stats
1216 // -----------------------------------------------------------------------
1217 #[test]
1218 fn test_classify_no_stats_change() {
1219 let learner = perceptron(2);
1220 learner.classify(&[1.0, 0.0]).expect("test: should succeed");
1221 assert_eq!(learner.stats().total_predictions, 0);
1222 }
1223
1224 // -----------------------------------------------------------------------
1225 // Test 36: evaluate_losses returns correct count
1226 // -----------------------------------------------------------------------
1227 #[test]
1228 fn test_evaluate_losses() {
1229 let learner = perceptron(2);
1230 let samples = vec![
1231 sample(vec![1.0, 0.0], 1.0),
1232 sample(vec![0.0, 1.0], -1.0),
1233 sample(vec![1.0, 1.0], 1.0),
1234 ];
1235 let losses = learner
1236 .evaluate_losses(&samples)
1237 .expect("test: should succeed");
1238 assert_eq!(losses.len(), 3);
1239 assert!(losses.iter().all(|&l| l >= 0.0));
1240 }
1241
1242 // -----------------------------------------------------------------------
1243 // Test 37: average_loss empty returns error
1244 // -----------------------------------------------------------------------
1245 #[test]
1246 fn test_average_loss_empty() {
1247 let learner = perceptron(2);
1248 let err = learner.average_loss(&[]).unwrap_err();
1249 assert_eq!(err, LearnerError::EmptyInput);
1250 }
1251
1252 // -----------------------------------------------------------------------
1253 // Test 38: SGD with LogLoss converges on simple data
1254 // -----------------------------------------------------------------------
1255 #[test]
1256 fn test_sgd_logloss_convergence() {
1257 let mut learner = OnlineLearner::new(
1258 OnlineAlgorithm::SgdMomentum {
1259 lr: 0.1,
1260 momentum: 0.9,
1261 l2_reg: 0.001,
1262 },
1263 1,
1264 OlLossFunction::LogLoss,
1265 );
1266 // Trivially separable 1-D data
1267 let pos = sample(vec![3.0], 1.0);
1268 let neg = sample(vec![-3.0], -1.0);
1269
1270 for _ in 0..200 {
1271 let _ = learner.update(&pos);
1272 let _ = learner.update(&neg);
1273 }
1274 assert_eq!(learner.classify(&[3.0]).expect("test: should succeed"), 1);
1275 assert_eq!(learner.classify(&[-3.0]).expect("test: should succeed"), -1);
1276 }
1277
1278 // -----------------------------------------------------------------------
1279 // Test 39: PA-I with different C values
1280 // -----------------------------------------------------------------------
1281 #[test]
1282 fn test_pa_c_parameter_effect() {
1283 // Larger C → more aggressive update → larger weight change per step
1284 let mut learner_low_c = pa(1, 0.1);
1285 let mut learner_high_c = pa(1, 100.0);
1286
1287 let s = sample(vec![1.0], 1.0);
1288 learner_low_c
1289 .update(&s)
1290 .expect("test: TD update should succeed");
1291 learner_high_c
1292 .update(&s)
1293 .expect("test: TD update should succeed");
1294
1295 // High C should produce a larger (or equal) weight update
1296 assert!(learner_high_c.weights()[0] >= learner_low_c.weights()[0]);
1297 }
1298
1299 // -----------------------------------------------------------------------
1300 // Test 40: OnlineLearnerStats weight_norm matches l2_norm
1301 // -----------------------------------------------------------------------
1302 #[test]
1303 fn test_stats_weight_norm() {
1304 let mut learner = perceptron(3);
1305 learner
1306 .update(&sample(vec![3.0, 0.0, 4.0], 1.0))
1307 .expect("test: should succeed");
1308 let stats = learner.stats();
1309 assert!((stats.weight_norm - learner.l2_norm()).abs() < 1e-10);
1310 }
1311
1312 // -----------------------------------------------------------------------
1313 // Test 41: record_prediction increments counts
1314 // -----------------------------------------------------------------------
1315 #[test]
1316 fn test_record_prediction() {
1317 let mut learner = perceptron(2);
1318 learner.record_prediction(true);
1319 learner.record_prediction(false);
1320 learner.record_prediction(true);
1321 let s = learner.stats();
1322 assert_eq!(s.total_predictions, 3);
1323 assert_eq!(s.correct_predictions, 2);
1324 }
1325
1326 // -----------------------------------------------------------------------
1327 // Test 42: LearnerError display messages
1328 // -----------------------------------------------------------------------
1329 #[test]
1330 fn test_error_display() {
1331 let e1 = LearnerError::DimensionMismatch {
1332 expected: 3,
1333 got: 2,
1334 };
1335 let e2 = LearnerError::EmptyInput;
1336 let e3 = LearnerError::InvalidLabel { label: 0.5 };
1337 assert!(e1.to_string().contains("3"));
1338 assert!(e2.to_string().contains("empty"));
1339 assert!(e3.to_string().contains("0.5"));
1340 }
1341
1342 // -----------------------------------------------------------------------
1343 // Test 43: OlLossFunction display
1344 // -----------------------------------------------------------------------
1345 #[test]
1346 fn test_loss_function_display() {
1347 assert_eq!(OlLossFunction::Hinge.to_string(), "Hinge");
1348 assert_eq!(OlLossFunction::SquaredHinge.to_string(), "SquaredHinge");
1349 assert_eq!(OlLossFunction::LogLoss.to_string(), "LogLoss");
1350 }
1351
1352 // -----------------------------------------------------------------------
1353 // Test 44: perceptron correctly handles negative class features
1354 // -----------------------------------------------------------------------
1355 #[test]
1356 fn test_perceptron_negative_class() {
1357 let mut learner = perceptron(2);
1358 let s = sample(vec![-1.0, -1.0], -1.0);
1359 // Initial score = 0, label*score = 0 ≤ 0 → update
1360 learner.update(&s).expect("test: TD update should succeed");
1361 // w += label * x = -1 * [-1, -1] = [1, 1] wait that's wrong
1362 // w += (-1) * (-1, -1) = (1, 1)
1363 assert_eq!(learner.weights()[0], 1.0);
1364 assert_eq!(learner.bias(), -1.0);
1365 }
1366
1367 // -----------------------------------------------------------------------
1368 // Test 45: multiple reset cycles
1369 // -----------------------------------------------------------------------
1370 #[test]
1371 fn test_multiple_reset_cycles() {
1372 let mut learner = perceptron(3);
1373 for _ in 0..3 {
1374 for _ in 0..5 {
1375 let _ = learner.update(&sample(vec![1.0, 0.0, 0.5], 1.0));
1376 }
1377 learner.reset();
1378 assert_eq!(learner.stats().total_updates, 0);
1379 assert!(learner.weights().iter().all(|&w| w == 0.0));
1380 }
1381 }
1382
1383 // -----------------------------------------------------------------------
1384 // Test 46: SGD with zero momentum behaves like vanilla SGD
1385 // -----------------------------------------------------------------------
1386 #[test]
1387 fn test_sgd_zero_momentum() {
1388 let mut learner = OnlineLearner::new(
1389 OnlineAlgorithm::SgdMomentum {
1390 lr: 0.5,
1391 momentum: 0.0,
1392 l2_reg: 0.0,
1393 },
1394 1,
1395 OlLossFunction::Hinge,
1396 );
1397 // x=[1], label=1, score=0, margin=0 < 1 → grad_score = -1
1398 // grad_w = -1 * 1 + 0 * 0 = -1
1399 // velocity = 0*0 - 0.5*(-1) = 0.5
1400 // w = 0 + 0.5 = 0.5
1401 let s = sample(vec![1.0], 1.0);
1402 learner.update(&s).expect("test: TD update should succeed");
1403 assert!((learner.weights()[0] - 0.5).abs() < 1e-10);
1404 }
1405
1406 // -----------------------------------------------------------------------
1407 // Test 47: SquaredHinge SGD gradient at margin boundary
1408 // -----------------------------------------------------------------------
1409 #[test]
1410 fn test_squared_hinge_sgd_boundary() {
1411 let mut learner = OnlineLearner::new(
1412 OnlineAlgorithm::SgdMomentum {
1413 lr: 0.1,
1414 momentum: 0.0,
1415 l2_reg: 0.0,
1416 },
1417 1,
1418 OlLossFunction::SquaredHinge,
1419 );
1420 // margin=1 → grad_score = 0 (outside margin)
1421 learner.weights[0] = 1.0;
1422 // score = 1.0, margin = 1 → grad_score = 0
1423 let w_before = learner.weights()[0];
1424 let s = sample(vec![1.0], 1.0);
1425 learner.update(&s).expect("test: TD update should succeed");
1426 assert_eq!(learner.weights()[0], w_before); // no update
1427 }
1428
1429 // -----------------------------------------------------------------------
1430 // Test 48: evaluate_losses empty returns EmptyInput
1431 // -----------------------------------------------------------------------
1432 #[test]
1433 fn test_evaluate_losses_empty() {
1434 let learner = perceptron(2);
1435 let err = learner.evaluate_losses(&[]).unwrap_err();
1436 assert_eq!(err, LearnerError::EmptyInput);
1437 }
1438
1439 // -----------------------------------------------------------------------
1440 // Test 49: batch_update increments total_updates correctly
1441 // -----------------------------------------------------------------------
1442 #[test]
1443 fn test_batch_update_stats_total_updates() {
1444 let mut learner = perceptron(2);
1445 let samples = vec![
1446 sample(vec![1.0, 0.0], 1.0),
1447 sample(vec![0.0, 1.0], -1.0),
1448 sample(vec![1.0, 1.0], 1.0),
1449 ];
1450 learner
1451 .batch_update(&samples)
1452 .expect("test: should succeed");
1453 assert_eq!(learner.stats().total_updates, 3);
1454 }
1455
1456 // -----------------------------------------------------------------------
1457 // Test 50: PA converges on 2-D linearly separable data
1458 // -----------------------------------------------------------------------
1459 #[test]
1460 fn test_pa_convergence_2d() {
1461 let mut learner = pa(2, 1.0);
1462 let samples: Vec<TrainingSample> = vec![
1463 sample(vec![2.0, 1.0], 1.0),
1464 sample(vec![1.0, 2.0], 1.0),
1465 sample(vec![-2.0, -1.0], -1.0),
1466 sample(vec![-1.0, -2.0], -1.0),
1467 ];
1468 for _ in 0..30 {
1469 for s in &samples {
1470 let _ = learner.update(s);
1471 }
1472 }
1473 let acc = learner.accuracy(&samples).expect("test: should succeed");
1474 assert_eq!(acc, 1.0);
1475 }
1476}