1use crate::error::{RillError, ensure_finite};
29use crate::loss::log_loss::sigmoid;
30use crate::sparse::{FeatureId, SparseFeatures};
31use crate::traits::{SparseClassifier, SparseRegressor};
32use std::collections::BTreeMap;
33
34#[derive(Debug, Clone)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct FtrlConfig {
42 pub alpha: f64,
44 pub beta: f64,
46 pub l1: f64,
48 pub l2: f64,
50}
51
52impl Default for FtrlConfig {
53 fn default() -> Self {
54 Self {
55 alpha: 0.1,
56 beta: 1.0,
57 l1: 1.0,
58 l2: 1.0,
59 }
60 }
61}
62
63fn validate_config(config: &FtrlConfig) -> Result<(), RillError> {
65 ensure_finite("alpha", config.alpha)?;
66 ensure_finite("beta", config.beta)?;
67 ensure_finite("l1", config.l1)?;
68 ensure_finite("l2", config.l2)?;
69 if config.alpha <= 0.0 {
70 return Err(RillError::InvalidParameter {
71 name: "alpha",
72 value: config.alpha,
73 });
74 }
75 if config.beta < 0.0 {
76 return Err(RillError::InvalidParameter {
77 name: "beta",
78 value: config.beta,
79 });
80 }
81 if config.l1 < 0.0 {
82 return Err(RillError::InvalidParameter {
83 name: "l1",
84 value: config.l1,
85 });
86 }
87 if config.l2 < 0.0 {
88 return Err(RillError::InvalidParameter {
89 name: "l2",
90 value: config.l2,
91 });
92 }
93 Ok(())
94}
95
96#[derive(Debug, Clone, Default)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
103pub struct FtrlParam {
104 z: f64,
106 n: f64,
108}
109
110impl FtrlParam {
111 fn weight(&self, config: &FtrlConfig) -> f64 {
115 if self.z.abs() <= config.l1 {
116 0.0
117 } else {
118 let sign = self.z.signum();
119 let numerator = -(self.z - sign * config.l1);
120 let denominator = config.l2 + (config.beta + self.n.sqrt()) / config.alpha;
121 numerator / denominator
122 }
123 }
124
125 fn intercept_weight(&self, config: &FtrlConfig) -> f64 {
130 if self.n == 0.0 {
131 0.0
132 } else {
133 let numerator = -self.z;
134 let denominator = config.l2 + (config.beta + self.n.sqrt()) / config.alpha;
135 numerator / denominator
136 }
137 }
138
139 fn update(&mut self, gradient: f64, weight: f64, config: &FtrlConfig) {
145 let n_old = self.n;
146 let n_new = n_old + gradient * gradient;
147 let sigma = (n_new.sqrt() - n_old.sqrt()) / config.alpha;
148 self.z += gradient - sigma * weight;
149 self.n = n_new;
150 }
151}
152
153fn compute_dot(
159 params: &BTreeMap<FeatureId, FtrlParam>,
160 config: &FtrlConfig,
161 features: &SparseFeatures,
162) -> Result<f64, RillError> {
163 if features.is_empty() {
164 return Err(RillError::EmptyFeatures);
165 }
166 let mut dot = 0.0;
167 for &(id, value) in features.values() {
168 ensure_finite("sparse_value", value)?;
169 if let Some(param) = params.get(&id) {
170 dot += param.weight(config) * value;
171 }
172 }
173 Ok(dot)
174}
175
176#[derive(Debug, Clone)]
195#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
196pub struct FtrlRegressor {
197 config: FtrlConfig,
198 params: BTreeMap<FeatureId, FtrlParam>,
199 intercept: FtrlParam,
200 samples_seen: u64,
201}
202
203impl FtrlRegressor {
204 pub fn new(config: FtrlConfig) -> Result<Self, RillError> {
208 validate_config(&config)?;
209 Ok(Self {
210 config,
211 params: BTreeMap::new(),
212 intercept: FtrlParam::default(),
213 samples_seen: 0,
214 })
215 }
216
217 pub const fn config(&self) -> &FtrlConfig {
219 &self.config
220 }
221
222 pub fn weights(&self) -> Vec<(FeatureId, f64)> {
227 self.params
228 .iter()
229 .map(|(&id, param)| (id, param.weight(&self.config)))
230 .filter(|&(_, w)| w != 0.0)
231 .collect()
232 }
233
234 pub fn intercept(&self) -> f64 {
236 self.intercept.intercept_weight(&self.config)
237 }
238
239 pub fn feature_count(&self) -> usize {
241 self.params.len()
242 }
243
244 fn predict_inner(&self, features: &SparseFeatures) -> Result<f64, RillError> {
246 let dot = compute_dot(&self.params, &self.config, features)?;
247 Ok(dot + self.intercept.intercept_weight(&self.config))
248 }
249}
250
251impl SparseRegressor for FtrlRegressor {
252 fn samples_seen(&self) -> u64 {
253 self.samples_seen
254 }
255
256 fn predict(&self, features: &SparseFeatures) -> Result<f64, RillError> {
257 self.predict_inner(features)
258 }
259
260 fn learn(&mut self, features: &SparseFeatures, target: f64) -> Result<(), RillError> {
261 if features.is_empty() {
262 return Err(RillError::EmptyFeatures);
263 }
264 ensure_finite("target", target)?;
265
266 let prediction = self.predict_inner(features)?;
267 let grad = prediction - target;
268
269 for &(id, value) in features.values() {
272 ensure_finite("sparse_value", value)?;
273 let g = grad * value;
274 let param = self.params.entry(id).or_default();
275 let w = param.weight(&self.config);
276 param.update(g, w, &self.config);
277 }
278
279 let w_b = self.intercept.intercept_weight(&self.config);
281 self.intercept.update(grad, w_b, &self.config);
282
283 self.samples_seen += 1;
284 Ok(())
285 }
286
287 fn reset(&mut self) {
288 self.params.clear();
289 self.intercept = FtrlParam::default();
290 self.samples_seen = 0;
291 }
292}
293
294#[derive(Debug, Clone)]
313#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
314pub struct FtrlClassifier {
315 config: FtrlConfig,
316 params: BTreeMap<FeatureId, FtrlParam>,
317 intercept: FtrlParam,
318 samples_seen: u64,
319}
320
321impl FtrlClassifier {
322 pub fn new(config: FtrlConfig) -> Result<Self, RillError> {
326 validate_config(&config)?;
327 Ok(Self {
328 config,
329 params: BTreeMap::new(),
330 intercept: FtrlParam::default(),
331 samples_seen: 0,
332 })
333 }
334
335 pub const fn config(&self) -> &FtrlConfig {
337 &self.config
338 }
339
340 pub fn weights(&self) -> Vec<(FeatureId, f64)> {
345 self.params
346 .iter()
347 .map(|(&id, param)| (id, param.weight(&self.config)))
348 .filter(|&(_, w)| w != 0.0)
349 .collect()
350 }
351
352 pub fn intercept(&self) -> f64 {
354 self.intercept.intercept_weight(&self.config)
355 }
356
357 pub fn feature_count(&self) -> usize {
359 self.params.len()
360 }
361
362 fn predict_proba_inner(&self, features: &SparseFeatures) -> Result<f64, RillError> {
364 let dot = compute_dot(&self.params, &self.config, features)?;
365 let logit = dot + self.intercept.intercept_weight(&self.config);
366 Ok(sigmoid(logit))
367 }
368}
369
370impl SparseClassifier for FtrlClassifier {
371 fn samples_seen(&self) -> u64 {
372 self.samples_seen
373 }
374
375 fn predict_proba(&self, features: &SparseFeatures) -> Result<f64, RillError> {
376 self.predict_proba_inner(features)
377 }
378
379 fn learn(&mut self, features: &SparseFeatures, target: bool) -> Result<(), RillError> {
380 if features.is_empty() {
381 return Err(RillError::EmptyFeatures);
382 }
383
384 let probability = self.predict_proba_inner(features)?;
385 let y = if target { 1.0 } else { 0.0 };
386 let grad = probability - y;
387
388 for &(id, value) in features.values() {
389 ensure_finite("sparse_value", value)?;
390 let g = grad * value;
391 let param = self.params.entry(id).or_default();
392 let w = param.weight(&self.config);
393 param.update(g, w, &self.config);
394 }
395
396 let w_b = self.intercept.intercept_weight(&self.config);
398 self.intercept.update(grad, w_b, &self.config);
399
400 self.samples_seen += 1;
401 Ok(())
402 }
403
404 fn reset(&mut self) {
405 self.params.clear();
406 self.intercept = FtrlParam::default();
407 self.samples_seen = 0;
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use rand::SeedableRng;
415
416 #[test]
421 fn cold_start_returns_zero() {
422 let model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
423 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
424 let pred = model.predict(&sf).unwrap();
425 assert!(pred.abs() < 1e-12);
426 }
427
428 #[test]
429 fn learn_linear_data_converges() {
430 let mut model = FtrlRegressor::new(FtrlConfig {
432 alpha: 0.5,
433 beta: 1.0,
434 l1: 0.0,
435 l2: 0.0,
436 })
437 .unwrap();
438 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(42);
439 let mut first_err = 0.0;
440 let mut last_err = 0.0;
441 for i in 0..500 {
442 let x = rand::Rng::gen_range(&mut rng, -1.0..1.0);
443 let y = 2.0 * x;
444 let sf = SparseFeatures::from_sorted(vec![(0, x)]).unwrap();
445 let pred = model.predict(&sf).unwrap();
446 let err = (pred - y).abs();
447 if i < 10 {
448 first_err += err;
449 }
450 if i >= 490 {
451 last_err += err;
452 }
453 model.learn(&sf, y).unwrap();
454 }
455 assert!(last_err < first_err, "error should decrease");
456 let weights = model.weights();
457 assert_eq!(weights.len(), 1);
458 assert!(
459 (weights[0].1 - 2.0).abs() < 0.5,
460 "weight should approach 2.0"
461 );
462 }
463
464 #[test]
465 fn l1_produces_sparse_weights() {
466 let mut model = FtrlRegressor::new(FtrlConfig {
468 alpha: 0.1,
469 beta: 1.0,
470 l1: 100.0,
471 l2: 0.0,
472 })
473 .unwrap();
474 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(1);
475 for _ in 0..200 {
476 let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
477 let x2 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
478 let y = 0.5 * x1;
479 let sf = SparseFeatures::from_sorted(vec![(0, x1), (1, x2)]).unwrap();
480 model.learn(&sf, y).unwrap();
481 }
482 let weights = model.weights();
483 assert!(
485 weights.is_empty(),
486 "weights should all be zero, got {weights:?}"
487 );
488 }
489
490 #[test]
491 fn dynamic_features() {
492 let mut model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
493 assert_eq!(model.feature_count(), 0);
494 let sf1 = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
495 model.learn(&sf1, 1.0).unwrap();
496 assert_eq!(model.feature_count(), 1);
497 let sf2 = SparseFeatures::from_sorted(vec![(5, 2.0)]).unwrap();
499 model.learn(&sf2, 2.0).unwrap();
500 assert_eq!(model.feature_count(), 2);
501 assert!(model.params.contains_key(&0));
503 assert!(model.params.contains_key(&5));
504 }
505
506 #[test]
507 fn predict_does_not_update_state() {
508 let mut model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
509 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
510 let _ = model.predict(&sf).unwrap();
511 assert_eq!(model.samples_seen(), 0);
512 assert_eq!(model.feature_count(), 0);
513 model.learn(&sf, 1.0).unwrap();
515 let count_after_learn = model.feature_count();
516 let _ = model.predict(&sf).unwrap();
517 assert_eq!(model.feature_count(), count_after_learn);
518 assert_eq!(model.samples_seen(), 1);
519 }
520
521 #[test]
522 fn non_finite_value_rejected() {
523 let model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
524 assert!(SparseFeatures::from_sorted(vec![(0, f64::NAN)]).is_err());
526 assert!(SparseFeatures::from_sorted(vec![(0, f64::INFINITY)]).is_err());
527 assert!(SparseFeatures::from_sorted(vec![(0, f64::NEG_INFINITY)]).is_err());
528 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
529 assert!(model.predict(&sf).is_ok());
530 }
531
532 #[test]
533 fn non_finite_target_rejected() {
534 let mut model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
535 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
536 assert!(model.learn(&sf, f64::NAN).is_err());
537 assert!(model.learn(&sf, f64::INFINITY).is_err());
538 assert!(model.learn(&sf, f64::NEG_INFINITY).is_err());
539 assert_eq!(model.samples_seen(), 0);
541 }
542
543 #[test]
544 fn empty_features_rejected() {
545 let mut model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
546 let sf = SparseFeatures::new();
547 assert!(model.predict(&sf).is_err());
548 assert!(model.learn(&sf, 1.0).is_err());
549 }
550
551 #[test]
552 fn reset_clears_state() {
553 let mut model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
554 let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 2.0)]).unwrap();
555 model.learn(&sf, 3.0).unwrap();
556 model.learn(&sf, 3.0).unwrap();
557 assert_eq!(model.samples_seen(), 2);
558 assert_eq!(model.feature_count(), 2);
559 model.reset();
560 assert_eq!(model.samples_seen(), 0);
561 assert_eq!(model.feature_count(), 0);
562 assert!(model.predict(&sf).unwrap().abs() < 1e-12);
563 }
564
565 #[test]
566 fn invalid_config_rejected() {
567 assert!(
568 FtrlRegressor::new(FtrlConfig {
569 alpha: 0.0,
570 ..FtrlConfig::default()
571 })
572 .is_err()
573 );
574 assert!(
575 FtrlRegressor::new(FtrlConfig {
576 alpha: -1.0,
577 ..FtrlConfig::default()
578 })
579 .is_err()
580 );
581 assert!(
582 FtrlRegressor::new(FtrlConfig {
583 beta: -1.0,
584 ..FtrlConfig::default()
585 })
586 .is_err()
587 );
588 assert!(
589 FtrlRegressor::new(FtrlConfig {
590 l1: -1.0,
591 ..FtrlConfig::default()
592 })
593 .is_err()
594 );
595 assert!(
596 FtrlRegressor::new(FtrlConfig {
597 l2: -1.0,
598 ..FtrlConfig::default()
599 })
600 .is_err()
601 );
602 assert!(
603 FtrlRegressor::new(FtrlConfig {
604 alpha: f64::NAN,
605 ..FtrlConfig::default()
606 })
607 .is_err()
608 );
609 }
610
611 #[test]
612 #[cfg(feature = "serde")]
613 fn serde_roundtrip() {
614 let mut model = FtrlRegressor::new(FtrlConfig {
615 alpha: 0.2,
616 beta: 0.5,
617 l1: 0.5,
618 l2: 0.5,
619 })
620 .unwrap();
621 let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (3, 2.0)]).unwrap();
622 model.learn(&sf, 5.0).unwrap();
623 let json = serde_json::to_string(&model).unwrap();
624 let restored: FtrlRegressor = serde_json::from_str(&json).unwrap();
625 assert_eq!(restored.samples_seen(), model.samples_seen());
626 assert_eq!(restored.feature_count(), model.feature_count());
627 let pred_orig = model.predict(&sf).unwrap();
628 let pred_restored = restored.predict(&sf).unwrap();
629 assert!((pred_orig - pred_restored).abs() < 1e-12);
630 }
631
632 #[test]
633 fn weights_returns_nonzero_only() {
634 let mut model = FtrlRegressor::new(FtrlConfig {
635 alpha: 0.5,
636 beta: 1.0,
637 l1: 0.0,
638 l2: 0.0,
639 })
640 .unwrap();
641 let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 0.0001)]).unwrap();
643 for _ in 0..50 {
644 model.learn(&sf, 1.0).unwrap();
645 }
646 let weights = model.weights();
647 for &(_, w) in &weights {
649 assert!(w != 0.0);
650 }
651 assert!(weights.iter().any(|&(id, _)| id == 0));
653 }
654
655 #[test]
656 fn multiple_features() {
657 let mut model = FtrlRegressor::new(FtrlConfig {
659 alpha: 0.5,
660 beta: 1.0,
661 l1: 0.0,
662 l2: 0.0,
663 })
664 .unwrap();
665 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(99);
666 for _ in 0..500 {
667 let x0 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
668 let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
669 let y = 1.0 * x0 - 1.0 * x1 + 0.5;
670 let sf = SparseFeatures::from_sorted(vec![(0, x0), (1, x1)]).unwrap();
671 model.learn(&sf, y).unwrap();
672 }
673 let weights = model.weights();
674 assert_eq!(weights.len(), 2);
675 let w0 = weights
676 .iter()
677 .find(|&&(id, _)| id == 0)
678 .map(|&(_, w)| w)
679 .unwrap();
680 let w1 = weights
681 .iter()
682 .find(|&&(id, _)| id == 1)
683 .map(|&(_, w)| w)
684 .unwrap();
685 assert!((w0 - 1.0).abs() < 0.5, "w0 should approach 1.0, got {w0}");
686 assert!((w1 + 1.0).abs() < 0.5, "w1 should approach -1.0, got {w1}");
687 assert!(
688 (model.intercept() - 0.5).abs() < 0.5,
689 "intercept should approach 0.5"
690 );
691 }
692
693 #[test]
694 fn intercept_learned() {
695 let mut model = FtrlRegressor::new(FtrlConfig {
698 alpha: 0.5,
699 beta: 1.0,
700 l1: 0.0,
701 l2: 0.0,
702 })
703 .unwrap();
704 let sf = SparseFeatures::from_sorted(vec![(0, 0.0)]).unwrap();
705 for _ in 0..300 {
706 model.learn(&sf, 3.0).unwrap();
707 }
708 let pred = model.predict(&sf).unwrap();
709 assert!(
710 (pred - 3.0).abs() < 0.5,
711 "prediction should approach 3.0, got {pred}"
712 );
713 assert!(
714 (model.intercept() - 3.0).abs() < 0.5,
715 "intercept should approach 3.0"
716 );
717 assert!(model.weights().is_empty());
719 }
720
721 #[test]
722 fn high_dim_sparse() {
723 let mut model = FtrlRegressor::new(FtrlConfig {
726 alpha: 0.3,
727 beta: 1.0,
728 l1: 0.0,
729 l2: 0.0,
730 })
731 .unwrap();
732 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(7);
733 let true_w = [1.0, -0.5, 2.0, 0.3, -1.5];
735 let mut first_err = 0.0;
736 let mut last_err = 0.0;
737 for i in 0..2000 {
738 let mut active: Vec<(FeatureId, f64)> = Vec::with_capacity(5);
739 for (j, &w) in true_w.iter().enumerate() {
740 let x = rand::Rng::gen_range(&mut rng, -1.0..1.0);
741 active.push((j as u64, x * w));
742 }
743 for k in 5..10 {
745 let x = rand::Rng::gen_range(&mut rng, -1.0..1.0);
746 active.push((k as u64 + 100, x));
747 }
748 active.sort_by_key(|(id, _)| *id);
749 let sf = SparseFeatures::from_sorted(active.clone()).unwrap();
750 let y: f64 = active.iter().take(5).map(|(_, v)| v).sum();
751 let pred = model.predict(&sf).unwrap();
752 let err = (pred - y).abs();
753 if i < 20 {
754 first_err += err;
755 }
756 if i >= 1980 {
757 last_err += err;
758 }
759 model.learn(&sf, y).unwrap();
760 }
761 assert!(
762 last_err < first_err,
763 "error should decrease in high-dim sparse"
764 );
765 }
766
767 #[test]
772 fn cold_start_returns_0_5() {
773 let model = FtrlClassifier::new(FtrlConfig::default()).unwrap();
774 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
775 let p = model.predict_proba(&sf).unwrap();
776 assert!((p - 0.5).abs() < 1e-12, "cold start should predict 0.5");
777 }
778
779 #[test]
780 fn learn_separable_data() {
781 let mut model = FtrlClassifier::new(FtrlConfig {
783 alpha: 0.5,
784 beta: 1.0,
785 l1: 0.0,
786 l2: 0.0,
787 })
788 .unwrap();
789 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(3);
790 for _ in 0..1000 {
791 let x0 = rand::Rng::gen_range(&mut rng, -2.0..2.0);
792 let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
793 let y = x0 > 0.0;
794 let sf = SparseFeatures::from_sorted(vec![(0, x0), (1, x1)]).unwrap();
795 model.learn(&sf, y).unwrap();
796 }
797 let p_pos = model
798 .predict_proba(&SparseFeatures::from_sorted(vec![(0, 2.0), (1, 0.0)]).unwrap())
799 .unwrap();
800 let p_neg = model
801 .predict_proba(&SparseFeatures::from_sorted(vec![(0, -2.0), (1, 0.0)]).unwrap())
802 .unwrap();
803 assert!(p_pos > 0.7, "p_pos should be high, got {p_pos}");
804 assert!(p_neg < 0.3, "p_neg should be low, got {p_neg}");
805 }
806
807 #[test]
808 fn classifier_l1_produces_sparse_weights() {
809 let mut model = FtrlClassifier::new(FtrlConfig {
810 alpha: 0.1,
811 beta: 1.0,
812 l1: 100.0,
813 l2: 0.0,
814 })
815 .unwrap();
816 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(5);
817 for _ in 0..200 {
818 let x0 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
819 let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
820 let y = x0 > 0.0;
821 let sf = SparseFeatures::from_sorted(vec![(0, x0), (1, x1)]).unwrap();
822 model.learn(&sf, y).unwrap();
823 }
824 let weights = model.weights();
825 assert!(
826 weights.is_empty(),
827 "weights should all be zero with high L1, got {weights:?}"
828 );
829 }
830
831 #[test]
832 fn classifier_dynamic_features() {
833 let mut model = FtrlClassifier::new(FtrlConfig::default()).unwrap();
834 assert_eq!(model.feature_count(), 0);
835 let sf1 = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
836 model.learn(&sf1, true).unwrap();
837 assert_eq!(model.feature_count(), 1);
838 let sf2 = SparseFeatures::from_sorted(vec![(10, 1.0)]).unwrap();
839 model.learn(&sf2, false).unwrap();
840 assert_eq!(model.feature_count(), 2);
841 }
842
843 #[test]
844 fn classifier_predict_does_not_update_state() {
845 let mut model = FtrlClassifier::new(FtrlConfig::default()).unwrap();
846 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
847 let _ = model.predict_proba(&sf).unwrap();
848 assert_eq!(model.samples_seen(), 0);
849 assert_eq!(model.feature_count(), 0);
850 model.learn(&sf, true).unwrap();
851 let count = model.feature_count();
852 let _ = model.predict_proba(&sf).unwrap();
853 assert_eq!(model.feature_count(), count);
854 assert_eq!(model.samples_seen(), 1);
855 }
856
857 #[test]
858 fn classifier_non_finite_value_rejected() {
859 let model = FtrlClassifier::new(FtrlConfig::default()).unwrap();
860 assert!(SparseFeatures::from_sorted(vec![(0, f64::NAN)]).is_err());
861 assert!(SparseFeatures::from_sorted(vec![(0, f64::INFINITY)]).is_err());
862 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
863 assert!(model.predict_proba(&sf).is_ok());
864 }
865
866 #[test]
867 fn classifier_empty_features_rejected() {
868 let mut model = FtrlClassifier::new(FtrlConfig::default()).unwrap();
869 let sf = SparseFeatures::new();
870 assert!(model.predict_proba(&sf).is_err());
871 assert!(model.learn(&sf, true).is_err());
872 }
873
874 #[test]
875 fn classifier_reset_clears_state() {
876 let mut model = FtrlClassifier::new(FtrlConfig::default()).unwrap();
877 let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
878 model.learn(&sf, true).unwrap();
879 model.learn(&sf, false).unwrap();
880 assert_eq!(model.samples_seen(), 2);
881 assert!(model.feature_count() > 0);
882 model.reset();
883 assert_eq!(model.samples_seen(), 0);
884 assert_eq!(model.feature_count(), 0);
885 let p = model.predict_proba(&sf).unwrap();
886 assert!((p - 0.5).abs() < 1e-12);
887 }
888
889 #[test]
890 fn classifier_invalid_config_rejected() {
891 assert!(
892 FtrlClassifier::new(FtrlConfig {
893 alpha: 0.0,
894 ..FtrlConfig::default()
895 })
896 .is_err()
897 );
898 assert!(
899 FtrlClassifier::new(FtrlConfig {
900 beta: -0.1,
901 ..FtrlConfig::default()
902 })
903 .is_err()
904 );
905 assert!(
906 FtrlClassifier::new(FtrlConfig {
907 l1: -1.0,
908 ..FtrlConfig::default()
909 })
910 .is_err()
911 );
912 assert!(
913 FtrlClassifier::new(FtrlConfig {
914 l2: -1.0,
915 ..FtrlConfig::default()
916 })
917 .is_err()
918 );
919 assert!(
920 FtrlClassifier::new(FtrlConfig {
921 alpha: f64::INFINITY,
922 ..FtrlConfig::default()
923 })
924 .is_err()
925 );
926 }
927
928 #[test]
929 #[cfg(feature = "serde")]
930 fn classifier_serde_roundtrip() {
931 let mut model = FtrlClassifier::new(FtrlConfig {
932 alpha: 0.3,
933 beta: 0.5,
934 l1: 0.1,
935 l2: 0.2,
936 })
937 .unwrap();
938 let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (2, -1.0)]).unwrap();
939 model.learn(&sf, true).unwrap();
940 model.learn(&sf, false).unwrap();
941 let json = serde_json::to_string(&model).unwrap();
942 let restored: FtrlClassifier = serde_json::from_str(&json).unwrap();
943 assert_eq!(restored.samples_seen(), model.samples_seen());
944 assert_eq!(restored.feature_count(), model.feature_count());
945 let p1 = model.predict_proba(&sf).unwrap();
946 let p2 = restored.predict_proba(&sf).unwrap();
947 assert!((p1 - p2).abs() < 1e-12);
948 }
949
950 #[test]
951 fn predict_proba_in_range() {
952 let mut model = FtrlClassifier::new(FtrlConfig {
953 alpha: 0.5,
954 beta: 1.0,
955 l1: 0.0,
956 l2: 0.0,
957 })
958 .unwrap();
959 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(17);
960 for _ in 0..200 {
961 let x0 = rand::Rng::gen_range(&mut rng, -5.0..5.0);
962 let x1 = rand::Rng::gen_range(&mut rng, -5.0..5.0);
963 let y = x0 > 0.0;
964 let sf = SparseFeatures::from_sorted(vec![(0, x0), (1, x1)]).unwrap();
965 model.learn(&sf, y).unwrap();
966 let p = model.predict_proba(&sf).unwrap();
967 assert!(p > 0.0 && p < 1.0, "probability must be in (0,1), got {p}");
968 }
969 }
970
971 #[test]
972 fn learn_improves_accuracy() {
973 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(21);
974 let test_set: Vec<(SparseFeatures, bool)> = (0..100)
976 .map(|_| {
977 let x0 = rand::Rng::gen_range(&mut rng, -2.0..2.0);
978 let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
979 let y = x0 + x1 > 0.0;
980 (
981 SparseFeatures::from_sorted(vec![(0, x0), (1, x1)]).unwrap(),
982 y,
983 )
984 })
985 .collect();
986
987 let mut model = FtrlClassifier::new(FtrlConfig {
988 alpha: 0.5,
989 beta: 1.0,
990 l1: 0.0,
991 l2: 0.0,
992 })
993 .unwrap();
994
995 let acc_before: f64 = test_set
997 .iter()
998 .map(|(sf, y)| {
999 let pred = model.predict(sf).unwrap();
1000 if pred == *y { 1.0 } else { 0.0 }
1001 })
1002 .sum::<f64>()
1003 / test_set.len() as f64;
1004
1005 for _ in 0..1000 {
1007 let x0 = rand::Rng::gen_range(&mut rng, -2.0..2.0);
1008 let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
1009 let y = x0 + x1 > 0.0;
1010 let sf = SparseFeatures::from_sorted(vec![(0, x0), (1, x1)]).unwrap();
1011 model.learn(&sf, y).unwrap();
1012 }
1013
1014 let acc_after: f64 = test_set
1015 .iter()
1016 .map(|(sf, y)| {
1017 let pred = model.predict(sf).unwrap();
1018 if pred == *y { 1.0 } else { 0.0 }
1019 })
1020 .sum::<f64>()
1021 / test_set.len() as f64;
1022
1023 assert!(
1024 acc_after > acc_before,
1025 "accuracy should improve: {acc_before} -> {acc_after}"
1026 );
1027 }
1028
1029 #[test]
1030 fn classifier_weights_returns_nonzero_only() {
1031 let mut model = FtrlClassifier::new(FtrlConfig {
1032 alpha: 0.5,
1033 beta: 1.0,
1034 l1: 0.0,
1035 l2: 0.0,
1036 })
1037 .unwrap();
1038 let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 0.0001)]).unwrap();
1039 for _ in 0..50 {
1040 model.learn(&sf, true).unwrap();
1041 }
1042 let weights = model.weights();
1043 for &(_, w) in &weights {
1044 assert!(w != 0.0);
1045 }
1046 }
1047
1048 #[test]
1049 fn classifier_multiple_features() {
1050 let mut model = FtrlClassifier::new(FtrlConfig {
1051 alpha: 0.5,
1052 beta: 1.0,
1053 l1: 0.0,
1054 l2: 0.0,
1055 })
1056 .unwrap();
1057 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(33);
1058 for _ in 0..1000 {
1059 let x0 = rand::Rng::gen_range(&mut rng, -2.0..2.0);
1060 let x1 = rand::Rng::gen_range(&mut rng, -2.0..2.0);
1061 let x2 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
1062 let y = x0 + x1 > 0.0;
1064 let sf = SparseFeatures::from_sorted(vec![(0, x0), (1, x1), (2, x2)]).unwrap();
1065 model.learn(&sf, y).unwrap();
1066 }
1067 let weights = model.weights();
1068 assert!(weights.iter().any(|&(id, _)| id == 0));
1070 assert!(weights.iter().any(|&(id, _)| id == 1));
1071 let p_pos = model
1073 .predict_proba(
1074 &SparseFeatures::from_sorted(vec![(0, 3.0), (1, 3.0), (2, 0.0)]).unwrap(),
1075 )
1076 .unwrap();
1077 let p_neg = model
1078 .predict_proba(
1079 &SparseFeatures::from_sorted(vec![(0, -3.0), (1, -3.0), (2, 0.0)]).unwrap(),
1080 )
1081 .unwrap();
1082 assert!(p_pos > 0.8);
1083 assert!(p_neg < 0.2);
1084 }
1085
1086 #[test]
1087 fn log_loss_converges() {
1088 let mut model = FtrlClassifier::new(FtrlConfig {
1090 alpha: 0.5,
1091 beta: 1.0,
1092 l1: 0.0,
1093 l2: 0.0,
1094 })
1095 .unwrap();
1096 let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(55);
1097 let mut first_loss = 0.0;
1098 let mut last_loss = 0.0;
1099 for i in 0..1000 {
1100 let x0 = rand::Rng::gen_range(&mut rng, -2.0..2.0);
1101 let x1 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
1102 let y = x0 > 0.0;
1103 let sf = SparseFeatures::from_sorted(vec![(0, x0), (1, x1)]).unwrap();
1104 let p = model.predict_proba(&sf).unwrap();
1105 let y_f = if y { 1.0 } else { 0.0 };
1106 let loss = -(y_f * p.ln() + (1.0 - y_f) * (1.0 - p).ln());
1107 if i < 20 {
1108 first_loss += loss;
1109 }
1110 if i >= 980 {
1111 last_loss += loss;
1112 }
1113 model.learn(&sf, y).unwrap();
1114 }
1115 assert!(last_loss < first_loss, "log loss should decrease");
1116 }
1117}