Skip to main content

incremental_rs/
logistic_regression.rs

1use crate::error::IncrementalError;
2use crate::learning_rate::LearningRateSchedule;
3use crate::IncrementalSupervisedEstimator;
4use ndarray::{Array1, Array2};
5use std::collections::BTreeSet;
6
7/// Multiclass strategy for incremental logistic regression.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum MulticlassStrategy {
10    /// One-vs-Rest strategy: maintains an internal array of binary models,
11    /// one per unique class encountered in the stream.
12    OneVsRest,
13}
14
15#[derive(Debug)]
16pub struct IncrementalLogisticRegression {
17    // For binary classification (1 model) or OvR components
18    weights: Option<Array2<f64>>, // Shape: (n_classes, n_features)
19    bias: Option<Array1<f64>>,    // Shape: (n_classes)
20    schedule: LearningRateSchedule,
21    step_count: usize,
22    l2_penalty: f64,
23    strategy: MulticlassStrategy,
24    seen_classes: BTreeSet<usize>,
25}
26
27impl IncrementalLogisticRegression {
28    pub fn new(
29        schedule: LearningRateSchedule,
30        l2_penalty: f64,
31        strategy: MulticlassStrategy,
32    ) -> Self {
33        Self {
34            weights: None,
35            bias: None,
36            schedule,
37            step_count: 0,
38            l2_penalty,
39            strategy,
40            seen_classes: BTreeSet::new(),
41        }
42    }
43
44    /// Returns the multiclass strategy configured for this model.
45    pub fn strategy(&self) -> &MulticlassStrategy {
46        &self.strategy
47    }
48
49    fn sigmoid(x: f64) -> f64 {
50        1.0 / (1.0 + (-x).exp())
51    }
52
53    /// Extends weight matrices dynamically if a new class label appears mid-stream.
54    fn ensure_class_capacity(&mut self, label: usize, n_features: usize) {
55        if !self.seen_classes.contains(&label) {
56            self.seen_classes.insert(label);
57            let target_rows = label + 1;
58
59            match (&mut self.weights, &mut self.bias) {
60                (Some(w), Some(b)) => {
61                    if w.nrows() < target_rows {
62                        let mut new_w = Array2::zeros((target_rows, n_features));
63                        new_w.slice_mut(ndarray::s![..w.nrows(), ..]).assign(w);
64                        *w = new_w;
65
66                        let mut new_b = Array1::zeros(target_rows);
67                        new_b.slice_mut(ndarray::s![..b.len()]).assign(b);
68                        *b = new_b;
69                    }
70                }
71                _ => {
72                    self.weights = Some(Array2::zeros((target_rows, n_features)));
73                    self.bias = Some(Array1::zeros(target_rows));
74                }
75            }
76        }
77    }
78
79    fn validate_batch(
80        &self,
81        batch_x: &Array2<f64>,
82        batch_y: &Array1<usize>,
83    ) -> Result<(), IncrementalError> {
84        if batch_x.nrows() == 0 {
85            return Err(IncrementalError::EmptyBatch);
86        }
87        if batch_x.nrows() != batch_y.len() {
88            return Err(IncrementalError::TargetDimensionMismatch {
89                target_len: batch_y.len(),
90                feature_rows: batch_x.nrows(),
91            });
92        }
93        if batch_x.iter().any(|v| !v.is_finite()) {
94            return Err(IncrementalError::NonFiniteInput);
95        }
96        if let Some(ref w) = self.weights {
97            if batch_x.ncols() != w.ncols() {
98                return Err(IncrementalError::DimensionMismatch {
99                    expected: w.ncols(),
100                    actual: batch_x.ncols(),
101                });
102            }
103        }
104        Ok(())
105    }
106
107    /// Fits a mini-batch with integer target labels (multiclass/binary).
108    pub fn partial_fit_labels(
109        &mut self,
110        batch_x: &Array2<f64>,
111        batch_y: &Array1<usize>,
112    ) -> Result<(), IncrementalError> {
113        self.validate_batch(batch_x, batch_y)?;
114
115        let n_samples = batch_x.nrows() as f64;
116        let n_features = batch_x.ncols();
117
118        // Register any newly encountered class labels dynamically
119        for &y in batch_y {
120            self.ensure_class_capacity(y, n_features);
121        }
122
123        let weights = self.weights.as_mut().unwrap();
124        let bias = self.bias.as_mut().unwrap();
125        let eta = self.schedule.calculate(self.step_count);
126
127        // One-vs-Rest Mini-Batch Gradient Step
128        for &class_idx in &self.seen_classes {
129            // Binary target indicator for this class
130            let y_binary: Array1<f64> = batch_y
131                .mapv(|y| if y == class_idx { 1.0 } else { 0.0 });
132
133            let w_c = weights.row(class_idx);
134            let b_c = bias[class_idx];
135
136            // Linear combinations & activation: z = X*w + b
137            let z = batch_x.dot(&w_c) + b_c;
138            let probs = z.mapv(Self::sigmoid);
139
140            // Gradient calculation for cross-entropy loss
141            let errors = &probs - &y_binary;
142            let mut w_grad = batch_x.t().dot(&errors) / n_samples;
143
144            if self.l2_penalty > 0.0 {
145                w_grad += &(&w_c * self.l2_penalty);
146            }
147            let b_grad = errors.sum() / n_samples;
148
149            // Update class parameters
150            let mut w_c_mut = weights.row_mut(class_idx);
151            w_c_mut -= &(w_grad * eta);
152            bias[class_idx] -= b_grad * eta;
153        }
154
155        self.step_count += 1;
156        Ok(())
157    }
158
159    /// Predict class probabilities for input features.
160    pub fn predict_proba(&self, x: &Array2<f64>) -> Result<Array2<f64>, IncrementalError> {
161        if x.nrows() == 0 {
162            return Err(IncrementalError::EmptyBatch);
163        }
164        if x.iter().any(|v| !v.is_finite()) {
165            return Err(IncrementalError::NonFiniteInput);
166        }
167
168        let (weights, bias) = match (&self.weights, &self.bias) {
169            (Some(w), Some(b)) => (w, b),
170            _ => return Err(IncrementalError::EmptyBatch),
171        };
172
173        if x.ncols() != weights.ncols() {
174            return Err(IncrementalError::DimensionMismatch {
175                expected: weights.ncols(),
176                actual: x.ncols(),
177            });
178        }
179
180        let mut raw_logits = x.dot(&weights.t()); // Shape: (n_samples, n_classes)
181        for (i, &b) in bias.iter().enumerate() {
182            raw_logits.column_mut(i).mapv_inplace(|v| Self::sigmoid(v + b));
183        }
184
185        Ok(raw_logits)
186    }
187
188    /// Predict discrete class labels.
189    pub fn predict_labels(&self, x: &Array2<f64>) -> Result<Array1<usize>, IncrementalError> {
190        let probs = self.predict_proba(x)?;
191        let mut preds = Vec::with_capacity(x.nrows());
192
193        for row in probs.rows() {
194            let mut max_idx = 0;
195            let mut max_prob = f64::NEG_INFINITY;
196            for (idx, &p) in row.iter().enumerate() {
197                if p > max_prob {
198                    max_prob = p;
199                    max_idx = idx;
200                }
201            }
202            preds.push(max_idx);
203        }
204
205        Ok(Array1::from(preds))
206    }
207}
208
209impl IncrementalSupervisedEstimator for IncrementalLogisticRegression {
210    /// Satisfies trait for continuous 0.0/1.0 targets in binary classification.
211    fn partial_fit(
212        &mut self,
213        batch_x: &Array2<f64>,
214        batch_y: &Array1<f64>,
215    ) -> Result<(), IncrementalError> {
216        let labels = batch_y.mapv(|val| val.round() as usize);
217        self.partial_fit_labels(batch_x, &labels)
218    }
219
220    /// Predicts target values as continuous class probabilities (for binary class 1).
221    fn predict(&self, x: &Array2<f64>) -> Result<Array1<f64>, IncrementalError> {
222        let probs = self.predict_proba(x)?;
223        if probs.ncols() > 1 {
224            Ok(probs.column(1).to_owned())
225        } else {
226            Ok(probs.column(0).to_owned())
227        }
228    }
229}