1use std::sync::Arc;
21
22use parking_lot::RwLock;
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25use tracing::debug;
26
27use scirs2_core::ndarray_ext::Array1;
28
29#[derive(Debug, Error)]
33pub enum RegressionError {
34 #[error("feature dimension mismatch: expected {expected}, got {actual}")]
36 DimensionMismatch { expected: usize, actual: usize },
37 #[error("model not ready: only {observed} of {required} samples observed")]
39 NotReady { observed: usize, required: usize },
40 #[error("invalid config: {0}")]
42 InvalidConfig(String),
43}
44
45pub type RegressionResult<T> = std::result::Result<T, RegressionError>;
47
48pub trait StreamRegressor: Send + Sync {
52 fn n_features(&self) -> usize;
54 fn n_observed(&self) -> u64;
56 fn observe(&self, features: &Array1<f64>, target: f64) -> RegressionResult<()>;
58 fn predict(&self, features: &Array1<f64>) -> RegressionResult<f64>;
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct LinearConfig {
67 pub n_features: usize,
69 pub learning_rate: f64,
71 pub l2: f64,
73 pub standardise_inputs: bool,
75 pub min_samples: u64,
77}
78
79impl Default for LinearConfig {
80 fn default() -> Self {
81 Self {
82 n_features: 4,
83 learning_rate: 0.01,
84 l2: 0.0,
85 standardise_inputs: true,
86 min_samples: 5,
87 }
88 }
89}
90
91impl LinearConfig {
92 fn validate(&self) -> RegressionResult<()> {
93 if self.n_features == 0 {
94 return Err(RegressionError::InvalidConfig(
95 "n_features must be > 0".into(),
96 ));
97 }
98 if !self.learning_rate.is_finite() || self.learning_rate <= 0.0 {
99 return Err(RegressionError::InvalidConfig(
100 "learning_rate must be positive".into(),
101 ));
102 }
103 if !self.l2.is_finite() || self.l2 < 0.0 {
104 return Err(RegressionError::InvalidConfig(
105 "l2 must be non-negative".into(),
106 ));
107 }
108 Ok(())
109 }
110}
111
112#[derive(Debug, Clone, Default)]
116struct FeatureStats {
117 count: u64,
118 mean: Array1<f64>,
119 m2: Array1<f64>,
120}
121
122impl FeatureStats {
123 fn new(n: usize) -> Self {
124 Self {
125 count: 0,
126 mean: Array1::zeros(n),
127 m2: Array1::zeros(n),
128 }
129 }
130
131 fn standardise(&self, x: &Array1<f64>) -> Array1<f64> {
133 const EPS: f64 = 1.0e-8;
134 if self.count < 2 {
135 return x.clone();
136 }
137 let n = x.len();
138 let mut out = Array1::zeros(n);
139 for i in 0..n {
140 let var = if self.count > 1 {
141 self.m2[i] / (self.count as f64 - 1.0)
142 } else {
143 0.0
144 };
145 let denom = (var + EPS).sqrt();
146 out[i] = (x[i] - self.mean[i]) / denom;
147 }
148 out
149 }
150
151 fn update(&mut self, x: &Array1<f64>) {
152 self.count += 1;
153 let count_f = self.count as f64;
154 for i in 0..x.len() {
155 let delta = x[i] - self.mean[i];
156 self.mean[i] += delta / count_f;
157 let delta2 = x[i] - self.mean[i];
158 self.m2[i] += delta * delta2;
159 }
160 }
161}
162
163struct LinearState {
165 weights: Array1<f64>,
166 bias: f64,
167 feature_stats: FeatureStats,
168 samples: u64,
169 last_loss: f64,
170}
171
172pub struct OnlineLinearRegressor {
174 config: LinearConfig,
175 state: Arc<RwLock<LinearState>>,
176}
177
178impl std::fmt::Debug for OnlineLinearRegressor {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 let st = self.state.read();
181 f.debug_struct("OnlineLinearRegressor")
182 .field("config", &self.config)
183 .field("samples", &st.samples)
184 .field("last_loss", &st.last_loss)
185 .finish()
186 }
187}
188
189impl OnlineLinearRegressor {
190 pub fn new(config: LinearConfig) -> RegressionResult<Self> {
192 config.validate()?;
193 let state = LinearState {
194 weights: Array1::zeros(config.n_features),
195 bias: 0.0,
196 feature_stats: FeatureStats::new(config.n_features),
197 samples: 0,
198 last_loss: 0.0,
199 };
200 Ok(Self {
201 config,
202 state: Arc::new(RwLock::new(state)),
203 })
204 }
205
206 pub fn weights(&self) -> Array1<f64> {
208 self.state.read().weights.clone()
209 }
210
211 pub fn bias(&self) -> f64 {
213 self.state.read().bias
214 }
215
216 pub fn last_loss(&self) -> f64 {
218 self.state.read().last_loss
219 }
220
221 fn check_dim(&self, x: &Array1<f64>) -> RegressionResult<()> {
222 if x.len() != self.config.n_features {
223 return Err(RegressionError::DimensionMismatch {
224 expected: self.config.n_features,
225 actual: x.len(),
226 });
227 }
228 Ok(())
229 }
230
231 fn forward(&self, normalised: &Array1<f64>) -> f64 {
232 let st = self.state.read();
233 let mut y = st.bias;
234 for i in 0..normalised.len() {
235 y += st.weights[i] * normalised[i];
236 }
237 y
238 }
239}
240
241impl StreamRegressor for OnlineLinearRegressor {
242 fn n_features(&self) -> usize {
243 self.config.n_features
244 }
245
246 fn n_observed(&self) -> u64 {
247 self.state.read().samples
248 }
249
250 fn observe(&self, features: &Array1<f64>, target: f64) -> RegressionResult<()> {
251 self.check_dim(features)?;
252 if !target.is_finite() {
253 return Err(RegressionError::InvalidConfig(
254 "target must be finite".into(),
255 ));
256 }
257
258 let normalised = if self.config.standardise_inputs {
259 let stats = self.state.read().feature_stats.clone();
260 stats.standardise(features)
261 } else {
262 features.clone()
263 };
264
265 let pred = self.forward(&normalised);
266 let err = pred - target;
267
268 {
269 let mut st = self.state.write();
270 for i in 0..self.config.n_features {
272 let grad = err * normalised[i] + self.config.l2 * st.weights[i];
273 st.weights[i] -= self.config.learning_rate * grad;
274 }
275 st.bias -= self.config.learning_rate * err;
277 st.feature_stats.update(features);
278 st.samples += 1;
279 st.last_loss = 0.5 * err * err;
280 }
281 Ok(())
282 }
283
284 fn predict(&self, features: &Array1<f64>) -> RegressionResult<f64> {
285 self.check_dim(features)?;
286 let observed = self.state.read().samples;
287 if observed < self.config.min_samples {
288 return Err(RegressionError::NotReady {
289 observed: observed as usize,
290 required: self.config.min_samples as usize,
291 });
292 }
293
294 let normalised = if self.config.standardise_inputs {
295 let stats = self.state.read().feature_stats.clone();
296 stats.standardise(features)
297 } else {
298 features.clone()
299 };
300 Ok(self.forward(&normalised))
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct GbtConfig {
309 pub n_features: usize,
311 pub max_trees: usize,
313 pub learning_rate: f64,
315 pub fit_buffer_size: usize,
317 pub min_samples: u64,
319}
320
321impl Default for GbtConfig {
322 fn default() -> Self {
323 Self {
324 n_features: 4,
325 max_trees: 16,
326 learning_rate: 0.1,
327 fit_buffer_size: 32,
328 min_samples: 16,
329 }
330 }
331}
332
333impl GbtConfig {
334 fn validate(&self) -> RegressionResult<()> {
335 if self.n_features == 0 || self.max_trees == 0 || self.fit_buffer_size == 0 {
336 return Err(RegressionError::InvalidConfig(
337 "n_features, max_trees and fit_buffer_size must all be > 0".into(),
338 ));
339 }
340 if !self.learning_rate.is_finite() || self.learning_rate <= 0.0 {
341 return Err(RegressionError::InvalidConfig(
342 "learning_rate must be positive".into(),
343 ));
344 }
345 Ok(())
346 }
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351struct DecisionStump {
352 feature: usize,
353 threshold: f64,
354 left_value: f64,
355 right_value: f64,
356}
357
358impl DecisionStump {
359 fn predict(&self, features: &Array1<f64>) -> f64 {
360 if features[self.feature] <= self.threshold {
361 self.left_value
362 } else {
363 self.right_value
364 }
365 }
366}
367
368struct GbtState {
370 bias: f64,
371 trees: Vec<DecisionStump>,
372 buffer: Vec<(Array1<f64>, f64)>,
374 samples: u64,
375 last_loss: f64,
376}
377
378pub struct StreamingGradientBoostedRegressor {
380 config: GbtConfig,
381 state: Arc<RwLock<GbtState>>,
382}
383
384impl std::fmt::Debug for StreamingGradientBoostedRegressor {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 let st = self.state.read();
387 f.debug_struct("StreamingGradientBoostedRegressor")
388 .field("config", &self.config)
389 .field("samples", &st.samples)
390 .field("ensemble_size", &st.trees.len())
391 .field("last_loss", &st.last_loss)
392 .finish()
393 }
394}
395
396impl StreamingGradientBoostedRegressor {
397 pub fn new(config: GbtConfig) -> RegressionResult<Self> {
399 config.validate()?;
400 let state = GbtState {
401 bias: 0.0,
402 trees: Vec::with_capacity(config.max_trees),
403 buffer: Vec::with_capacity(config.fit_buffer_size),
404 samples: 0,
405 last_loss: 0.0,
406 };
407 Ok(Self {
408 config,
409 state: Arc::new(RwLock::new(state)),
410 })
411 }
412
413 pub fn ensemble_size(&self) -> usize {
415 self.state.read().trees.len()
416 }
417
418 pub fn last_loss(&self) -> f64 {
420 self.state.read().last_loss
421 }
422
423 fn check_dim(&self, features: &Array1<f64>) -> RegressionResult<()> {
424 if features.len() != self.config.n_features {
425 return Err(RegressionError::DimensionMismatch {
426 expected: self.config.n_features,
427 actual: features.len(),
428 });
429 }
430 Ok(())
431 }
432
433 fn ensemble_predict(&self, features: &Array1<f64>) -> f64 {
434 let st = self.state.read();
435 let mut y = st.bias;
436 for tree in &st.trees {
437 y += self.config.learning_rate * tree.predict(features);
438 }
439 y
440 }
441
442 fn fit_stump_from_buffer(buffer: &[(Array1<f64>, f64)], n_features: usize) -> DecisionStump {
446 debug_assert!(
447 !buffer.is_empty(),
448 "fit_stump_from_buffer called with empty buffer"
449 );
450 let n = buffer.len() as f64;
451 let mean_target = buffer.iter().map(|(_, t)| *t).sum::<f64>() / n;
452 let total_ss: f64 = buffer.iter().map(|(_, t)| (t - mean_target).powi(2)).sum();
453
454 let mut best = DecisionStump {
455 feature: 0,
456 threshold: 0.0,
457 left_value: mean_target,
458 right_value: mean_target,
459 };
460 let mut best_gain = -1.0;
461
462 for f in 0..n_features {
463 let mut values: Vec<f64> = buffer.iter().map(|(x, _)| x[f]).collect();
465 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
466 let mid = values.len() / 2;
467 let threshold = if values.len() % 2 == 0 && mid > 0 {
468 0.5 * (values[mid - 1] + values[mid])
469 } else if !values.is_empty() {
470 values[mid]
471 } else {
472 continue;
473 };
474
475 let mut left_sum = 0.0;
476 let mut left_count = 0usize;
477 let mut right_sum = 0.0;
478 let mut right_count = 0usize;
479 for (x, t) in buffer {
480 if x[f] <= threshold {
481 left_sum += *t;
482 left_count += 1;
483 } else {
484 right_sum += *t;
485 right_count += 1;
486 }
487 }
488 if left_count == 0 || right_count == 0 {
489 continue;
490 }
491 let left_mean = left_sum / left_count as f64;
492 let right_mean = right_sum / right_count as f64;
493 let mut split_ss = 0.0;
494 for (x, t) in buffer {
495 if x[f] <= threshold {
496 split_ss += (t - left_mean).powi(2);
497 } else {
498 split_ss += (t - right_mean).powi(2);
499 }
500 }
501 let gain = total_ss - split_ss;
502 if gain > best_gain {
503 best_gain = gain;
504 best = DecisionStump {
505 feature: f,
506 threshold,
507 left_value: left_mean,
508 right_value: right_mean,
509 };
510 }
511 }
512 best
513 }
514}
515
516impl StreamRegressor for StreamingGradientBoostedRegressor {
517 fn n_features(&self) -> usize {
518 self.config.n_features
519 }
520
521 fn n_observed(&self) -> u64 {
522 self.state.read().samples
523 }
524
525 fn observe(&self, features: &Array1<f64>, target: f64) -> RegressionResult<()> {
526 self.check_dim(features)?;
527 if !target.is_finite() {
528 return Err(RegressionError::InvalidConfig(
529 "target must be finite".into(),
530 ));
531 }
532 let pred = self.ensemble_predict(features);
533 let residual = target - pred;
534
535 let mut st = self.state.write();
536 st.samples += 1;
537 st.last_loss = 0.5 * residual * residual;
538 st.buffer.push((features.clone(), residual));
539
540 if st.buffer.len() >= self.config.fit_buffer_size {
541 let buffer = std::mem::take(&mut st.buffer);
542 let stump = Self::fit_stump_from_buffer(&buffer, self.config.n_features);
543 let feature = stump.feature;
544 let threshold = stump.threshold;
545 if st.trees.len() < self.config.max_trees {
546 st.trees.push(stump);
547 } else {
548 st.trees.remove(0);
550 st.trees.push(stump);
551 }
552 let avg_target = buffer.iter().map(|(_, t)| *t).sum::<f64>() / buffer.len() as f64;
555 st.bias += self.config.learning_rate * avg_target;
556 debug!(
557 "GBT fit a new stump: feature={}, threshold={:.4}, ensemble={}",
558 feature,
559 threshold,
560 st.trees.len()
561 );
562 }
563 Ok(())
564 }
565
566 fn predict(&self, features: &Array1<f64>) -> RegressionResult<f64> {
567 self.check_dim(features)?;
568 let observed = self.state.read().samples;
569 if observed < self.config.min_samples {
570 return Err(RegressionError::NotReady {
571 observed: observed as usize,
572 required: self.config.min_samples as usize,
573 });
574 }
575 Ok(self.ensemble_predict(features))
576 }
577}
578
579#[cfg(test)]
582mod tests {
583 use super::*;
584
585 fn lin_target(x: &Array1<f64>) -> f64 {
586 2.0 * x[0] - 3.0 * x[1] + 5.0 * x[2] + 7.0
588 }
589
590 #[test]
591 fn linear_regressor_converges_on_linear_target() {
592 let cfg = LinearConfig {
593 n_features: 3,
594 learning_rate: 0.05,
595 l2: 0.0,
596 standardise_inputs: true,
597 min_samples: 5,
598 };
599 let model = OnlineLinearRegressor::new(cfg).expect("ok");
600 for i in 0..2000 {
601 let x = Array1::from_vec(vec![
602 ((i % 13) as f64) * 0.5,
603 ((i % 7) as f64) * 0.25,
604 ((i % 5) as f64) * 1.0,
605 ]);
606 let y = lin_target(&x);
607 model.observe(&x, y).expect("observe");
608 }
609 let probe = Array1::from_vec(vec![1.0, 1.0, 1.0]);
610 let pred = model.predict(&probe).expect("ready");
611 let want = lin_target(&probe); assert!(
613 (pred - want).abs() < 1.5,
614 "linear regressor diverged: pred={pred}, want={want}"
615 );
616 }
617
618 #[test]
619 fn linear_regressor_dimension_mismatch() {
620 let cfg = LinearConfig {
621 n_features: 3,
622 ..Default::default()
623 };
624 let model = OnlineLinearRegressor::new(cfg).expect("ok");
625 let bad = Array1::from_vec(vec![1.0, 2.0]);
626 let err = model.observe(&bad, 1.0).expect_err("should fail");
627 assert!(matches!(err, RegressionError::DimensionMismatch { .. }));
628 }
629
630 #[test]
631 fn linear_regressor_not_ready() {
632 let cfg = LinearConfig {
633 n_features: 2,
634 min_samples: 10,
635 ..Default::default()
636 };
637 let model = OnlineLinearRegressor::new(cfg).expect("ok");
638 let probe = Array1::from_vec(vec![0.0, 0.0]);
639 let err = model.predict(&probe).expect_err("should fail");
640 assert!(matches!(err, RegressionError::NotReady { .. }));
641 }
642
643 #[test]
644 fn linear_regressor_invalid_config() {
645 let cfg = LinearConfig {
646 n_features: 0,
647 ..Default::default()
648 };
649 let err = OnlineLinearRegressor::new(cfg).expect_err("should fail");
650 assert!(matches!(err, RegressionError::InvalidConfig(_)));
651 }
652
653 fn nonlinear_target(x: &Array1<f64>) -> f64 {
654 x[0] * x[0] + 0.5 * x[1].sin()
656 }
657
658 #[test]
659 fn gbt_regressor_learns_nonlinear_pattern() {
660 let cfg = GbtConfig {
661 n_features: 2,
662 max_trees: 24,
663 learning_rate: 0.1,
664 fit_buffer_size: 16,
665 min_samples: 32,
666 };
667 let model = StreamingGradientBoostedRegressor::new(cfg).expect("ok");
668 for i in 0..1500 {
669 let x0 = ((i % 11) as f64) * 0.2 - 1.0;
670 let x1 = ((i % 17) as f64) * 0.3 - 2.5;
671 let x = Array1::from_vec(vec![x0, x1]);
672 let y = nonlinear_target(&x);
673 model.observe(&x, y).expect("observe");
674 }
675 let probe = Array1::from_vec(vec![0.5, 1.0]);
676 let pred = model.predict(&probe).expect("ready");
677 let want = nonlinear_target(&probe);
678 assert!(
681 (pred - want).abs() < 1.5,
682 "gbt regressor too far off: pred={pred}, want={want}"
683 );
684 assert!(model.ensemble_size() > 0, "ensemble should be non-empty");
685 }
686
687 #[test]
688 fn gbt_ensemble_size_capped() {
689 let cfg = GbtConfig {
690 n_features: 1,
691 max_trees: 4,
692 learning_rate: 0.1,
693 fit_buffer_size: 4,
694 min_samples: 4,
695 };
696 let model = StreamingGradientBoostedRegressor::new(cfg).expect("ok");
697 for i in 0..200 {
699 let x = Array1::from_vec(vec![i as f64]);
700 model.observe(&x, x[0] * 0.3).expect("observe");
701 }
702 assert!(model.ensemble_size() <= 4);
703 }
704
705 #[test]
706 fn gbt_invalid_config() {
707 let cfg = GbtConfig {
708 max_trees: 0,
709 ..Default::default()
710 };
711 let err = StreamingGradientBoostedRegressor::new(cfg).expect_err("should fail");
712 assert!(matches!(err, RegressionError::InvalidConfig(_)));
713 }
714}