incremental_rs/
logistic_regression.rs1use crate::error::IncrementalError;
2use crate::learning_rate::LearningRateSchedule;
3use crate::IncrementalSupervisedEstimator;
4use ndarray::{Array1, Array2};
5use std::collections::BTreeSet;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum MulticlassStrategy {
10 OneVsRest,
13}
14
15#[derive(Debug)]
16pub struct IncrementalLogisticRegression {
17 weights: Option<Array2<f64>>, bias: Option<Array1<f64>>, 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 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 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 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 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 for &class_idx in &self.seen_classes {
129 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 let z = batch_x.dot(&w_c) + b_c;
138 let probs = z.mapv(Self::sigmoid);
139
140 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 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 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()); 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 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 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 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}