Skip to main content

rill_ml/models/
logistic_regression.rs

1//! Online logistic regression for binary classification.
2//!
3//! Uses a numerically stable sigmoid and binary cross-entropy (log) loss.
4
5use crate::error::{
6    RillError, checked_finite_add, checked_increment, ensure_finite, validate_features,
7};
8use crate::loss::log_loss::{BinaryLogLoss, sigmoid};
9use crate::optim::Optimizer;
10use crate::traits::OnlineBinaryClassifier;
11
12/// Configuration for [`LogisticRegression`].
13#[derive(Debug, Clone)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct LogisticRegressionConfig {
16    /// The optimizer (SGD or AdaGrad).
17    pub optimizer: Optimizer,
18    /// The log loss configuration.
19    pub loss: BinaryLogLoss,
20}
21
22impl Default for LogisticRegressionConfig {
23    fn default() -> Self {
24        Self {
25            optimizer: Optimizer::sgd(1, Default::default()).expect("default optimizer"),
26            loss: BinaryLogLoss::new(),
27        }
28    }
29}
30
31/// Online logistic regression model.
32///
33/// Predicts `P(y=1 | x) = sigmoid(w·x + b)`. Learning uses the gradient of
34/// the binary log loss w.r.t. the logit, which simplifies to `p - y`.
35#[derive(Debug, Clone)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub struct LogisticRegression {
38    feature_count: usize,
39    weights: Vec<f64>,
40    intercept: f64,
41    optimizer: Optimizer,
42    loss: BinaryLogLoss,
43    samples_seen: u64,
44}
45
46impl LogisticRegression {
47    /// Create a new logistic regression model.
48    pub fn new(feature_count: usize, config: LogisticRegressionConfig) -> Result<Self, RillError> {
49        if feature_count == 0 {
50            return Err(RillError::EmptyFeatures);
51        }
52        if config.optimizer.param_count() != feature_count + 1 {
53            return Err(RillError::DimensionMismatch {
54                expected: feature_count + 1,
55                actual: config.optimizer.param_count(),
56            });
57        }
58        Ok(Self {
59            feature_count,
60            weights: vec![0.0; feature_count],
61            intercept: 0.0,
62            optimizer: config.optimizer,
63            loss: config.loss,
64            samples_seen: 0,
65        })
66    }
67
68    /// The learned weights.
69    pub fn weights(&self) -> &[f64] {
70        &self.weights
71    }
72
73    /// The learned intercept (bias).
74    pub const fn intercept(&self) -> f64 {
75        self.intercept
76    }
77
78    /// Compute the logit `w·x + b`.
79    fn logit(&self, features: &[f64]) -> Result<f64, RillError> {
80        validate_features(self.feature_count, features)?;
81        let dot = self.weights.iter().zip(features.iter()).try_fold(
82            0.0,
83            |sum, (&weight, &feature)| {
84                let term = weight * feature;
85                ensure_finite("logit term", term)?;
86                checked_finite_add(sum, term, "logit")
87            },
88        )?;
89        checked_finite_add(dot, self.intercept, "logit")
90    }
91}
92
93impl OnlineBinaryClassifier for LogisticRegression {
94    fn feature_count(&self) -> usize {
95        self.feature_count
96    }
97
98    fn samples_seen(&self) -> u64 {
99        self.samples_seen
100    }
101
102    fn predict_proba(&self, features: &[f64]) -> Result<f64, RillError> {
103        let z = self.logit(features)?;
104        Ok(sigmoid(z))
105    }
106
107    fn learn(&mut self, features: &[f64], target: bool) -> Result<(), RillError> {
108        validate_features(self.feature_count, features)?;
109        let next_samples = checked_increment(self.samples_seen, "logistic regression sample")?;
110        let z = self.logit(features)?;
111        let p = sigmoid(z);
112        // gradient of log loss w.r.t. logit is (p - y)
113        let grad = self.loss.gradient_wrt_logit(p, target);
114        ensure_finite("loss gradient", grad)?;
115        let grad_weights = features
116            .iter()
117            .map(|&feature| {
118                let gradient = grad * feature;
119                ensure_finite("weight gradient", gradient)?;
120                Ok(gradient)
121            })
122            .collect::<Result<Vec<_>, RillError>>()?;
123        let grad_intercept = grad;
124        self.optimizer.step(
125            &mut self.weights,
126            &mut self.intercept,
127            &grad_weights,
128            grad_intercept,
129        )?;
130        self.samples_seen = next_samples;
131        Ok(())
132    }
133
134    fn reset(&mut self) {
135        for w in &mut self.weights {
136            *w = 0.0;
137        }
138        self.intercept = 0.0;
139        self.optimizer.reset();
140        self.samples_seen = 0;
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::optim::SgdConfig;
148    use rand::SeedableRng;
149
150    fn make_model(d: usize, lr: f64) -> LogisticRegression {
151        LogisticRegression::new(
152            d,
153            LogisticRegressionConfig {
154                optimizer: Optimizer::sgd(
155                    d,
156                    SgdConfig {
157                        learning_rate: lr,
158                        l2: 0.0,
159                    },
160                )
161                .unwrap(),
162                loss: BinaryLogLoss::new(),
163            },
164        )
165        .unwrap()
166    }
167
168    #[test]
169    fn predict_proba_in_range() {
170        let model = make_model(2, 0.1);
171        let p = model.predict_proba(&[1.0, 2.0]).unwrap();
172        assert!(p > 0.0 && p < 1.0);
173        // cold start: weights=0, intercept=0 -> sigmoid(0) = 0.5
174        assert!((p - 0.5).abs() < 1e-12);
175    }
176
177    #[test]
178    fn learn_separable_data() {
179        let mut model = make_model(2, 0.5);
180        let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(3);
181        for _ in 0..1000 {
182            // class 1: x1 > 0, class 0: x1 < 0
183            let x1 = rand::Rng::gen_range(&mut rng, -2.0..2.0);
184            let x2 = rand::Rng::gen_range(&mut rng, -1.0..1.0);
185            let y = x1 > 0.0;
186            model.learn(&[x1, x2], y).unwrap();
187        }
188        let p_pos = model.predict_proba(&[2.0, 0.0]).unwrap();
189        let p_neg = model.predict_proba(&[-2.0, 0.0]).unwrap();
190        assert!(p_pos > 0.7, "p_pos = {p_pos}");
191        assert!(p_neg < 0.3, "p_neg = {p_neg}");
192    }
193
194    #[test]
195    fn predict_does_not_update_state() {
196        let model = make_model(1, 0.1);
197        let _ = model.predict_proba(&[1.0]).unwrap();
198        assert_eq!(model.samples_seen(), 0);
199    }
200
201    #[test]
202    fn dimension_mismatch_rejected() {
203        let mut model = make_model(3, 0.1);
204        assert!(model.predict_proba(&[1.0, 2.0]).is_err());
205        assert!(model.learn(&[1.0, 2.0], true).is_err());
206    }
207
208    #[test]
209    fn reset_clears_state() {
210        let mut model = make_model(1, 0.1);
211        model.learn(&[1.0], true).unwrap();
212        model.reset();
213        assert_eq!(model.samples_seen(), 0);
214        assert!((model.predict_proba(&[1.0]).unwrap() - 0.5).abs() < 1e-12);
215    }
216}