Skip to main content

rill_ml/models/
ftrl.rs

1//! FTRL-Proximal online learning for sparse features.
2//!
3//! Implements the Follow-The-Regularized-Leader Proximal algorithm,
4//! which is well-suited for high-dimensional sparse data. L1
5//! regularization produces sparse weight vectors, and the per-coordinate
6//! learning rate adapts to feature frequency.
7//!
8//! See: McMahan et al., "Ad Click Prediction: a View from the Trenches"
9//! (KDD 2013).
10//!
11//! # Per-coordinate learning rate
12//!
13//! `eta_i = alpha / (beta + sqrt(n_i))`
14//!
15//! # Weight computation
16//!
17//! For feature `i`:
18//!
19//! ```text
20//! if |z_i| <= lambda1:
21//!     w_i = 0
22//! else:
23//!     w_i = -(z_i - sign(z_i) * lambda1) / (lambda2 + (beta + sqrt(n_i)) / alpha)
24//! ```
25//!
26//! The intercept uses `lambda1 = 0` (no L1 regularization).
27
28use 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/// Configuration for FTRL models.
35///
36/// Controls the per-coordinate learning rate and regularization strengths.
37/// All fields must be finite; `alpha` must be strictly positive and the
38/// regularization parameters must be non-negative.
39#[derive(Debug, Clone)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct FtrlConfig {
42    /// Alpha: learning rate scaling. Must be `> 0`.
43    pub alpha: f64,
44    /// Beta: smoothing constant. Must be `>= 0`.
45    pub beta: f64,
46    /// L1 regularization strength. Must be `>= 0`.
47    pub l1: f64,
48    /// L2 regularization strength. Must be `>= 0`.
49    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
63/// Validate FTRL configuration parameters.
64fn 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/// Per-feature FTRL state.
97///
98/// Tracks the sum of (sigma-corrected) gradients `z` and the sum of squared
99/// gradients `n`. The per-coordinate adaptive learning rate is derived from
100/// `n`: features seen more frequently get smaller steps.
101#[derive(Debug, Clone, Default)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
103pub struct FtrlParam {
104    /// Sum of gradients (with sigma correction).
105    z: f64,
106    /// Sum of squared gradients.
107    n: f64,
108}
109
110impl FtrlParam {
111    /// Compute the FTRL weight given the config.
112    ///
113    /// Returns `0` when `|z| <= l1` (L1 soft-thresholding).
114    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    /// Compute the intercept weight (no L1 regularization).
126    ///
127    /// Returns `0` when no gradient has been observed yet (`n == 0`),
128    /// avoiding a potential `0 / 0` when `l2` and `beta` are both zero.
129    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    /// Update with a gradient and the pre-computed current weight.
140    ///
141    /// `sigma = (sqrt(n_new) - sqrt(n_old)) / alpha`
142    /// `z += g - sigma * w`
143    /// `n += g^2`
144    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
153/// Compute the dot product `w · x` over sparse features.
154///
155/// Iterates only over the features present in `features` (not all stored
156/// params), looking up each feature's current FTRL weight. Feature values
157/// are validated for finiteness.
158fn 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/// FTRL regressor with squared loss.
177///
178/// Learns `y ≈ w · x + b` incrementally. The gradient of the squared loss
179/// w.r.t. the prediction is `prediction - target`, so each feature's
180/// gradient is `(prediction - target) * x_i`.
181///
182/// # Examples
183///
184/// ```
185/// use rill_ml::models::{FtrlConfig, FtrlRegressor};
186/// use rill_ml::sparse::SparseFeatures;
187/// use rill_ml::SparseRegressor;
188///
189/// let mut model = FtrlRegressor::new(FtrlConfig::default()).unwrap();
190/// let sf = SparseFeatures::from_sorted(vec![(0, 1.0), (1, 2.0)]).unwrap();
191/// let _pred = model.predict(&sf).unwrap();
192/// model.learn(&sf, 3.0).unwrap();
193/// ```
194#[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    /// Create a new FTRL regressor.
205    ///
206    /// Returns an error if the configuration is invalid.
207    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    /// The model configuration.
218    pub const fn config(&self) -> &FtrlConfig {
219        &self.config
220    }
221
222    /// Return the current non-zero feature weights, sorted by `FeatureId`.
223    ///
224    /// Features whose FTRL weight is exactly zero (due to L1
225    /// soft-thresholding or never having been updated) are excluded.
226    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    /// Compute the current intercept (bias) weight.
235    pub fn intercept(&self) -> f64 {
236        self.intercept.intercept_weight(&self.config)
237    }
238
239    /// Number of distinct features the model has seen.
240    pub fn feature_count(&self) -> usize {
241        self.params.len()
242    }
243
244    /// Compute the raw prediction `w · x + b` without updating state.
245    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        // Update each feature's params. entry().or_default() creates new
270        // feature state on demand, supporting dynamic feature growth.
271        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        // Update intercept with no L1 regularization.
280        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/// FTRL binary classifier with log loss.
295///
296/// Predicts `P(y=1 | x) = sigmoid(w · x + b)`. The gradient of the log loss
297/// w.r.t. the logit simplifies to `probability - target`, so each feature's
298/// gradient is `(probability - target) * x_i`.
299///
300/// # Examples
301///
302/// ```
303/// use rill_ml::models::{FtrlClassifier, FtrlConfig};
304/// use rill_ml::sparse::SparseFeatures;
305/// use rill_ml::SparseClassifier;
306///
307/// let mut model = FtrlClassifier::new(FtrlConfig::default()).unwrap();
308/// let sf = SparseFeatures::from_sorted(vec![(0, 1.0)]).unwrap();
309/// let _proba = model.predict_proba(&sf).unwrap();
310/// model.learn(&sf, true).unwrap();
311/// ```
312#[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    /// Create a new FTRL classifier.
323    ///
324    /// Returns an error if the configuration is invalid.
325    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    /// The model configuration.
336    pub const fn config(&self) -> &FtrlConfig {
337        &self.config
338    }
339
340    /// Return the current non-zero feature weights, sorted by `FeatureId`.
341    ///
342    /// Features whose FTRL weight is exactly zero (due to L1
343    /// soft-thresholding or never having been updated) are excluded.
344    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    /// Compute the current intercept (bias) weight.
353    pub fn intercept(&self) -> f64 {
354        self.intercept.intercept_weight(&self.config)
355    }
356
357    /// Number of distinct features the model has seen.
358    pub fn feature_count(&self) -> usize {
359        self.params.len()
360    }
361
362    /// Compute the probability `sigmoid(w · x + b)` without updating state.
363    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        // Update intercept with no L1 regularization.
397        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    // -----------------------------------------------------------------
417    // FtrlRegressor tests
418    // -----------------------------------------------------------------
419
420    #[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        // y = 2 * x, single feature
431        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        // High L1 should drive most weights to zero.
467        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        // With very high L1, all weights should be zero.
484        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        // A new feature id appears.
498        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        // Feature 0 still present.
502        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        // Learn once, then predict again.
514        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        // SparseFeatures::from_sorted rejects non-finite values at construction.
525        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        // State should not change on error.
540        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        // Learn feature 0 strongly, feature 1 barely.
642        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        // All returned weights should be non-zero.
648        for &(_, w) in &weights {
649            assert!(w != 0.0);
650        }
651        // Feature 0 should be in the list.
652        assert!(weights.iter().any(|&(id, _)| id == 0));
653    }
654
655    #[test]
656    fn multiple_features() {
657        // y = 1.0 * x0 + (-1.0) * x1 + 0.5
658        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        // y = 3.0 (constant), single feature with value 0.0 so that only
696        // the intercept can learn (feature gradient is always 0).
697        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        // Feature weight should be 0 (never updated since x=0).
718        assert!(model.weights().is_empty());
719    }
720
721    #[test]
722    fn high_dim_sparse() {
723        // 1000 possible features, only 5 active per sample.
724        // Target is a linear combination of the active features.
725        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        // True weights for features 0..5.
734        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            // Add some noise features with zero contribution.
744            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    // -----------------------------------------------------------------
768    // FtrlClassifier tests
769    // -----------------------------------------------------------------
770
771    #[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        // Linearly separable: class 1 when x0 > 0.
782        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        // Generate a fixed test set.
975        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        // Accuracy before learning (always predicts 0.5 -> threshold 0.5 -> true).
996        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        // Train on fresh data.
1006        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            // y = 1 if x0 + x1 > 0
1063            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        // Features 0 and 1 should have non-zero weights; feature 2 may or may not.
1069        assert!(weights.iter().any(|&(id, _)| id == 0));
1070        assert!(weights.iter().any(|&(id, _)| id == 1));
1071        // Verify prediction quality.
1072        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        // Average log loss should decrease over training.
1089        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}