chronix_analytics/forecast/
linear_regression.rs1use 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#[derive(serde::Serialize, serde::Deserialize)]
10pub struct LinearRegressionModel {
11 params: ModelParams,
12 n_points: usize,
13 sum_x: f64,
15 sum_y: f64,
16 sum_xy: f64,
17 sum_xx: f64,
18 sum_yy: f64,
19 last_ts: i64,
25 fitted: bool,
26}
27
28impl LinearRegressionModel {
29 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 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 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 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 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 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 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 #[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 #[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}