Skip to main content

oxirs_stream/ml/
regression.rs

1//! # Online regression models for streaming
2//!
3//! Implements two regression models that learn incrementally from a stream of
4//! `(features, target)` observations and produce predictions in real time.
5//!
6//! ## Models
7//!
8//! * [`OnlineLinearRegressor`] — least-mean-squares (LMS) online linear regression
9//!   with optional L2 regularisation. Numerically stable using Welford's
10//!   algorithm to track per-feature mean/variance for adaptive normalisation.
11//! * [`StreamingGradientBoostedRegressor`] — Gradient-boosted tree-style
12//!   regressor for streams. Uses a finite ensemble of small piece-wise constant
13//!   "decision stumps" (single split, two leaves) trained on the residuals of
14//!   the running ensemble. The ensemble is a fixed-capacity ring; the oldest
15//!   tree is replaced by a freshly fit one once the ring is full.
16//!
17//! Both models implement the [`StreamRegressor`] trait so they can be plugged
18//! into a higher-level operator (see `ml/mod.rs::StreamingModelRunner`).
19
20use 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// ─── Errors ─────────────────────────────────────────────────────────────────
30
31/// Errors raised by the regression models.
32#[derive(Debug, Error)]
33pub enum RegressionError {
34    /// Feature vector length did not match the model's configured dimension.
35    #[error("feature dimension mismatch: expected {expected}, got {actual}")]
36    DimensionMismatch { expected: usize, actual: usize },
37    /// The model has not seen enough samples yet to make a prediction.
38    #[error("model not ready: only {observed} of {required} samples observed")]
39    NotReady { observed: usize, required: usize },
40    /// A configuration value was out of range.
41    #[error("invalid config: {0}")]
42    InvalidConfig(String),
43}
44
45/// Convenience alias.
46pub type RegressionResult<T> = std::result::Result<T, RegressionError>;
47
48// ─── StreamRegressor trait ──────────────────────────────────────────────────
49
50/// Common interface for online regressors used in stream operators.
51pub trait StreamRegressor: Send + Sync {
52    /// Configured number of input features.
53    fn n_features(&self) -> usize;
54    /// Number of `(features, target)` samples observed so far.
55    fn n_observed(&self) -> u64;
56    /// Update the model with a single observation.
57    fn observe(&self, features: &Array1<f64>, target: f64) -> RegressionResult<()>;
58    /// Produce a prediction for `features` if the model is ready.
59    fn predict(&self, features: &Array1<f64>) -> RegressionResult<f64>;
60}
61
62// ─── OnlineLinearRegressor ──────────────────────────────────────────────────
63
64/// Configuration for [`OnlineLinearRegressor`].
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct LinearConfig {
67    /// Number of input features.
68    pub n_features: usize,
69    /// Learning rate of the LMS update step.
70    pub learning_rate: f64,
71    /// L2 regularisation strength (`0.0` disables it).
72    pub l2: f64,
73    /// Whether to standardise inputs using a running mean / variance estimate.
74    pub standardise_inputs: bool,
75    /// Minimum number of samples before [`StreamRegressor::predict`] returns.
76    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/// Per-feature running statistics computed via Welford's algorithm.
113///
114/// Stable in single precision over very long streams.
115#[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    /// Standardise `x` in place: `(x - mean) / sqrt(var + eps)`.
132    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
163/// Inner mutable state of the linear regressor.
164struct LinearState {
165    weights: Array1<f64>,
166    bias: f64,
167    feature_stats: FeatureStats,
168    samples: u64,
169    last_loss: f64,
170}
171
172/// Online least-mean-squares linear regressor.
173pub 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    /// Build a regressor from a config.
191    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    /// Snapshot of the current weights (for diagnostics or persistence).
207    pub fn weights(&self) -> Array1<f64> {
208        self.state.read().weights.clone()
209    }
210
211    /// Current bias term.
212    pub fn bias(&self) -> f64 {
213        self.state.read().bias
214    }
215
216    /// Last per-sample squared loss.
217    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            // LMS update: w := w - lr * (err * x + l2 * w)
271            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            // Bias does not get L2 regularised.
276            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// ─── StreamingGradientBoostedRegressor ─────────────────────────────────────
305
306/// Configuration for [`StreamingGradientBoostedRegressor`].
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct GbtConfig {
309    /// Number of input features.
310    pub n_features: usize,
311    /// Maximum number of stumps in the ensemble.
312    pub max_trees: usize,
313    /// Learning rate (shrinkage) applied to each tree's contribution.
314    pub learning_rate: f64,
315    /// Number of buffered observations used to fit each new stump.
316    pub fit_buffer_size: usize,
317    /// Minimum number of total observations before [`StreamingGradientBoostedRegressor::predict`] returns.
318    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/// A single decision stump with one feature index, one threshold and two leaves.
350#[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
368/// Mutable state of [`StreamingGradientBoostedRegressor`].
369struct GbtState {
370    bias: f64,
371    trees: Vec<DecisionStump>,
372    /// Buffered observations awaiting a new stump fit.
373    buffer: Vec<(Array1<f64>, f64)>,
374    samples: u64,
375    last_loss: f64,
376}
377
378/// Streaming gradient-boosted regressor.
379pub 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    /// Build a regressor from config.
398    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    /// Number of stumps currently in the ensemble.
414    pub fn ensemble_size(&self) -> usize {
415        self.state.read().trees.len()
416    }
417
418    /// Last per-batch mean squared error.
419    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    /// Fit a stump on the buffered residuals using the per-feature variance reduction
443    /// criterion. The chosen threshold is the median value of the candidate feature
444    /// (a robust, deterministic split point that does not require sorting all candidates).
445    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            // Threshold = sample median for feature `f` (robust to outliers).
464            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                // Ring-replace the oldest tree.
549                st.trees.remove(0);
550                st.trees.push(stump);
551            }
552            // Refresh bias: simple moving average of observed targets stays stable
553            // but we also drift it toward the buffer's mean.
554            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// ─── Tests ──────────────────────────────────────────────────────────────────
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    fn lin_target(x: &Array1<f64>) -> f64 {
586        // y = 2 x0 - 3 x1 + 5 x2 + 7
587        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); // 2 - 3 + 5 + 7 = 11
612        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        // y = x0^2 + 0.5 sin(x1)
655        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        // Stumps cannot perfectly represent a quadratic, but should track within
679        // a generous error band.
680        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        // Push enough observations to trigger many fits beyond the cap.
698        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}