1use 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#[derive(Debug, Clone)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct LogisticRegressionConfig {
16 pub optimizer: Optimizer,
18 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#[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 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 pub fn weights(&self) -> &[f64] {
70 &self.weights
71 }
72
73 pub const fn intercept(&self) -> f64 {
75 self.intercept
76 }
77
78 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 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 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 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}