Skip to main content

chronix_analytics/forecast/
linear_regression.rs

1//! Linear Regression forecast model.
2
3use crate::forecast::error::ForecastError;
4use crate::forecast::result::{ForecastResult, ModelParams, ModelType};
5use crate::forecast::traits::ForecastModel;
6use crate::forecast::util::{median_interval, validate_input};
7
8/// Ordinary Least Squares linear regression for trend forecasting.
9#[derive(serde::Serialize, serde::Deserialize)]
10pub struct LinearRegressionModel {
11    params: ModelParams,
12    n_points: usize,
13    // Running sums for online update (Welford's)
14    sum_x: f64,
15    sum_y: f64,
16    sum_xy: f64,
17    sum_xx: f64,
18    sum_yy: f64,
19    /// The real last training timestamp. Reconstructing it as
20    /// `start_ts + (n-1)*interval` puts the forecasts *inside* the observed
21    /// window whenever the series has gaps, because the median interval is
22    /// shorter than the average one. `SesModel` and the rest of the models
23    /// keep the actual timestamp; so does this one.
24    last_ts: i64,
25    fitted: bool,
26}
27
28impl LinearRegressionModel {
29    /// Creates a new linear regression model.
30    pub fn new() -> Self {
31        Self {
32            params: ModelParams::LinearRegression {
33                slope: 0.0,
34                intercept: 0.0,
35                r_squared: 0.0,
36                residual_std: 0.0,
37                start_ts: 0,
38                interval_ns: 0,
39            },
40            n_points: 0,
41            sum_x: 0.0,
42            sum_y: 0.0,
43            sum_xy: 0.0,
44            sum_xx: 0.0,
45            sum_yy: 0.0,
46            last_ts: 0,
47            fitted: false,
48        }
49    }
50
51    fn recompute_params(&mut self, values: &[f64], start_ts: i64, interval_ns: i64) {
52        let n = self.n_points as f64;
53        let mean_x = self.sum_x / n;
54        let mean_y = self.sum_y / n;
55
56        let ss_xx = self.sum_xx - n * mean_x * mean_x;
57        let ss_xy = self.sum_xy - n * mean_x * mean_y;
58
59        let slope = if ss_xx.abs() > 1e-15 {
60            ss_xy / ss_xx
61        } else {
62            0.0
63        };
64        let intercept = mean_y - slope * mean_x;
65
66        // R² and residual std
67        let ss_yy: f64 = values.iter().map(|&y| (y - mean_y).powi(2)).sum();
68        let ss_res: f64 = values
69            .iter()
70            .enumerate()
71            .map(|(i, &y)| {
72                let x = i as f64;
73                let pred = slope * x + intercept;
74                (y - pred).powi(2)
75            })
76            .sum();
77
78        let r_squared = if ss_yy.abs() > 1e-15 {
79            1.0 - ss_res / ss_yy
80        } else {
81            1.0
82        };
83
84        let residual_std = if self.n_points > 2 {
85            (ss_res / (self.n_points - 2) as f64).sqrt()
86        } else {
87            0.0
88        };
89
90        self.params = ModelParams::LinearRegression {
91            slope,
92            intercept,
93            r_squared,
94            residual_std,
95            start_ts,
96            interval_ns,
97        };
98    }
99}
100
101impl Default for LinearRegressionModel {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl ForecastModel for LinearRegressionModel {
108    fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
109        let _start = std::time::Instant::now();
110        validate_input(timestamps, values)?;
111        if values.len() < 2 {
112            return Err(ForecastError::InsufficientData {
113                min: 2,
114                got: values.len(),
115            });
116        }
117
118        let start_ts = timestamps[0];
119        let interval_ns = median_interval(timestamps);
120
121        // Normalize timestamps to index-based x values
122        self.sum_x = 0.0;
123        self.sum_y = 0.0;
124        self.sum_xy = 0.0;
125        self.sum_xx = 0.0;
126        self.sum_yy = 0.0;
127        self.n_points = values.len();
128
129        for (i, &y) in values.iter().enumerate() {
130            let x = i as f64;
131            self.sum_x += x;
132            self.sum_y += y;
133            self.sum_xy += x * y;
134            self.sum_xx += x * x;
135            self.sum_yy += y * y;
136        }
137
138        self.last_ts = timestamps[timestamps.len() - 1];
139        self.recompute_params(values, start_ts, interval_ns);
140        self.fitted = true;
141        metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "linear_regression").record(_start.elapsed().as_secs_f64());
142        Ok(())
143    }
144
145    fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
146        let _start = std::time::Instant::now();
147        if !self.fitted {
148            return Err(ForecastError::NotFitted);
149        }
150
151        let ModelParams::LinearRegression {
152            slope,
153            intercept,
154            residual_std,
155            interval_ns,
156            ..
157        } = &self.params
158        else {
159            return Err(ForecastError::InvalidInput(
160                "unexpected model params variant".into(),
161            ));
162        };
163
164        let z = 1.96;
165        let last_ts = self.last_ts;
166        let mut values = Vec::with_capacity(horizon);
167        let mut timestamps = Vec::with_capacity(horizon);
168        let mut lower = Vec::with_capacity(horizon);
169        let mut upper = Vec::with_capacity(horizon);
170
171        // Centered sum of squares: Sxx = Σ(xi - x̄)² = sum_xx - n * x̄²
172        let n_f = self.n_points as f64;
173        let x_bar = (n_f - 1.0) / 2.0;
174        let sxx_centered = (self.sum_xx - n_f * x_bar * x_bar).max(1.0);
175
176        for h in 1..=horizon {
177            let x = (self.n_points + h - 1) as f64;
178            let ts = last_ts.saturating_add((h as i64).saturating_mul(*interval_ns));
179            let pred = slope * x + intercept;
180            let width =
181                z * residual_std * (1.0 + 1.0 / n_f + (x - x_bar).powi(2) / sxx_centered).sqrt();
182            values.push(pred);
183            timestamps.push(ts);
184            lower.push(pred - width);
185            upper.push(pred + width);
186        }
187
188        metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "linear_regression").record(_start.elapsed().as_secs_f64());
189        Ok(ForecastResult {
190            values,
191            timestamps,
192            confidence_lower: lower,
193            confidence_upper: upper,
194            confidence_level: 0.95,
195        })
196    }
197
198    fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
199        if !self.fitted {
200            return Err(ForecastError::NotFitted);
201        }
202        self.last_ts = timestamp;
203
204        let x = self.n_points as f64;
205        self.sum_x += x;
206        self.sum_y += value;
207        self.sum_xy += x * value;
208        self.sum_xx += x * x;
209        self.sum_yy += value * value;
210        self.n_points += 1;
211
212        // Recompute slope/intercept from running sums
213        let ModelParams::LinearRegression {
214            start_ts,
215            interval_ns,
216            ..
217        } = &self.params
218        else {
219            return Err(ForecastError::InvalidInput(
220                "unexpected model params variant".into(),
221            ));
222        };
223
224        let start_ts = *start_ts;
225        let interval_ns = *interval_ns;
226
227        let n = self.n_points as f64;
228        let mean_x = self.sum_x / n;
229        let mean_y = self.sum_y / n;
230        let ss_xx = self.sum_xx - n * mean_x * mean_x;
231        let ss_xy = self.sum_xy - n * mean_x * mean_y;
232        let ss_yy = self.sum_yy - n * mean_y * mean_y;
233        let slope = if ss_xx.abs() > 1e-15 {
234            ss_xy / ss_xx
235        } else {
236            0.0
237        };
238        let intercept = mean_y - slope * mean_x;
239
240        // Incremental R² and residual_std from running sums
241        let ss_res = (ss_yy - slope * ss_xy).max(0.0);
242        let r_squared = if ss_yy.abs() > 1e-15 {
243            1.0 - ss_res / ss_yy
244        } else {
245            // Zero variance in y — a perfect horizontal line explains all data.
246            1.0
247        };
248        let residual_std = if self.n_points > 2 {
249            (ss_res / (self.n_points - 2) as f64).sqrt()
250        } else {
251            0.0
252        };
253
254        self.params = ModelParams::LinearRegression {
255            slope,
256            intercept,
257            r_squared,
258            residual_std,
259            start_ts,
260            interval_ns,
261        };
262
263        Ok(())
264    }
265
266    fn model_type(&self) -> ModelType {
267        ModelType::LinearRegression
268    }
269
270    fn params(&self) -> &ModelParams {
271        &self.params
272    }
273}
274
275impl crate::forecast::storage::ModelStore for LinearRegressionModel {}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// With gaps in the series the median interval is shorter than the mean
282    /// one, so `start_ts + (n-1)*median` lands *before* the last observation
283    /// and every forecast timestamp falls inside the observed window.
284    /// Timestamps: 0, 1, 2, 3, 4, 104 (seconds). median interval = 1 s, so
285    /// the reconstructed "last" was 5 s while the real last is 104 s.
286    #[test]
287    fn forecast_timestamps_start_after_the_last_observation() {
288        const S: i64 = 1_000_000_000;
289        let ts: Vec<i64> = vec![0, S, 2 * S, 3 * S, 4 * S, 104 * S];
290        let vals: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
291        let mut model = LinearRegressionModel::new();
292        model.fit(&ts, &vals).unwrap();
293        let r = model.predict(3).unwrap();
294        assert_eq!(r.timestamps, vec![105 * S, 106 * S, 107 * S]);
295    }
296
297    /// `update` must carry the timestamp forward too.
298    #[test]
299    fn update_advances_the_forecast_origin() {
300        const S: i64 = 1_000_000_000;
301        let ts: Vec<i64> = (0..10).map(|i| i * S).collect();
302        let vals: Vec<f64> = (0..10).map(|i| i as f64).collect();
303        let mut model = LinearRegressionModel::new();
304        model.fit(&ts, &vals).unwrap();
305        model.update(50 * S, 50.0).unwrap();
306        assert_eq!(model.predict(1).unwrap().timestamps, vec![51 * S]);
307    }
308
309    #[test]
310    fn perfectly_linear() {
311        let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
312        let vals: Vec<f64> = (0..100).map(|i| i as f64 * 3.0 + 5.0).collect();
313        let mut model = LinearRegressionModel::new();
314        model.fit(&ts, &vals).unwrap();
315
316        let ModelParams::LinearRegression {
317            slope,
318            intercept,
319            r_squared,
320            ..
321        } = model.params()
322        else {
323            panic!()
324        };
325        assert!((slope - 3.0).abs() < 1e-6);
326        assert!((intercept - 5.0).abs() < 1e-6);
327        assert!((r_squared - 1.0).abs() < 1e-6);
328
329        let result = model.predict(5).unwrap();
330        assert!((result.values[0] - (100.0 * 3.0 + 5.0)).abs() < 1e-6);
331    }
332
333    #[test]
334    fn noisy_linear() {
335        let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
336        let vals: Vec<f64> = (0..200)
337            .map(|i| {
338                let noise = ((i * 7 + 3) % 11) as f64 - 5.0;
339                i as f64 * 2.0 + 10.0 + noise
340            })
341            .collect();
342        let mut model = LinearRegressionModel::new();
343        model.fit(&ts, &vals).unwrap();
344
345        let ModelParams::LinearRegression {
346            slope, r_squared, ..
347        } = model.params()
348        else {
349            panic!()
350        };
351        assert!((slope - 2.0).abs() < 1.0);
352        assert!(*r_squared > 0.9);
353    }
354
355    #[test]
356    fn predict_extrapolates() {
357        let ts: Vec<i64> = (0..50).map(|i| i * 1_000_000_000).collect();
358        let vals: Vec<f64> = (0..50).map(|i| i as f64 * 10.0).collect();
359        let mut model = LinearRegressionModel::new();
360        model.fit(&ts, &vals).unwrap();
361        let result = model.predict(3).unwrap();
362        assert!((result.values[0] - 500.0).abs() < 1.0);
363        assert!((result.values[1] - 510.0).abs() < 1.0);
364        assert!((result.values[2] - 520.0).abs() < 1.0);
365    }
366
367    #[test]
368    fn online_update() {
369        let ts: Vec<i64> = (0..10).map(|i| i * 1_000_000_000).collect();
370        let vals: Vec<f64> = (0..10).map(|i| i as f64 * 5.0).collect();
371        let mut model = LinearRegressionModel::new();
372        model.fit(&ts, &vals).unwrap();
373        model.update(10_000_000_000, 50.0).unwrap();
374
375        let ModelParams::LinearRegression { slope, .. } = model.params() else {
376            panic!()
377        };
378        assert!((slope - 5.0).abs() < 1.0);
379    }
380
381    #[test]
382    fn insufficient_data() {
383        let mut model = LinearRegressionModel::new();
384        assert!(model.fit(&[0], &[1.0]).is_err());
385    }
386}