chronix_analytics/forecast/
ses.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)]
13pub struct SesModel {
14 alpha: Option<f64>,
15 params: ModelParams,
16 last_ts: i64,
17 interval_ns: i64,
18 n_points: usize,
19 fitted: bool,
20}
21
22impl SesModel {
23 pub fn new(alpha: Option<f64>) -> Self {
25 Self {
26 alpha,
27 params: ModelParams::Ses {
28 alpha: 0.0,
29 level: 0.0,
30 residual_std: 0.0,
31 },
32 last_ts: 0,
33 interval_ns: 0,
34 n_points: 0,
35 fitted: false,
36 }
37 }
38
39 fn optimize_alpha(values: &[f64]) -> f64 {
41 let result = crate::forecast::optimizer::minimize_brent(
42 |alpha| Self::compute_mse(values, alpha),
43 0.01,
44 0.99,
45 1e-8,
46 100,
47 );
48 result.params[0].clamp(0.01, 0.99)
49 }
50
51 fn compute_mse(values: &[f64], alpha: f64) -> f64 {
52 if values.len() < 2 {
53 return 0.0;
54 }
55 let one_minus = 1.0 - alpha;
56 let mut level = values[0];
57 let mut sse = 0.0;
58 for &value in values.iter().skip(1) {
59 let err = value - level;
60 sse += err * err;
61 level = alpha * value + one_minus * level;
62 }
63 sse / (values.len() - 1) as f64
64 }
65}
66
67impl ForecastModel for SesModel {
68 #[tracing::instrument(skip_all, level = "debug")]
69 fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
70 let _start = std::time::Instant::now();
71 validate_input(timestamps, values)?;
72 if values.len() < 2 {
73 return Err(ForecastError::InsufficientData {
74 min: 2,
75 got: values.len(),
76 });
77 }
78
79 let alpha = self.alpha.unwrap_or_else(|| Self::optimize_alpha(values));
80 let one_minus = 1.0 - alpha;
81
82 let mut level = values[0];
84 let mut sse = 0.0;
85 for &value in values.iter().skip(1) {
86 let err = value - level;
87 sse += err * err;
88 level = alpha * value + one_minus * level;
89 }
90
91 let dof = (values.len() as f64 - 2.0).max(1.0);
94 let residual_std = (sse / dof).sqrt();
95
96 self.interval_ns = median_interval(timestamps);
97 self.last_ts = timestamps[timestamps.len() - 1];
98 self.n_points = values.len();
99
100 self.params = ModelParams::Ses {
101 alpha,
102 level,
103 residual_std,
104 };
105 self.fitted = true;
106 metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "ses")
107 .record(_start.elapsed().as_secs_f64());
108 Ok(())
109 }
110
111 #[tracing::instrument(skip_all, level = "debug")]
112 fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
113 let _start = std::time::Instant::now();
114 if !self.fitted {
115 return Err(ForecastError::NotFitted);
116 }
117
118 let ModelParams::Ses {
119 alpha,
120 level,
121 residual_std,
122 } = &self.params
123 else {
124 return Err(ForecastError::InvalidInput(
125 "unexpected model params variant".into(),
126 ));
127 };
128
129 let z = 1.96; let mut values = Vec::with_capacity(horizon);
131 let mut timestamps = Vec::with_capacity(horizon);
132 let mut lower = Vec::with_capacity(horizon);
133 let mut upper = Vec::with_capacity(horizon);
134
135 for h in 1..=horizon {
136 let ts = self
137 .last_ts
138 .saturating_add((h as i64).saturating_mul(self.interval_ns));
139 let width = z * residual_std * (1.0 + (h as f64 - 1.0) * alpha * alpha).sqrt();
140 values.push(*level);
141 timestamps.push(ts);
142 lower.push(level - width);
143 upper.push(level + width);
144 }
145
146 metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "ses")
147 .record(_start.elapsed().as_secs_f64());
148 Ok(ForecastResult {
149 values,
150 timestamps,
151 confidence_lower: lower,
152 confidence_upper: upper,
153 confidence_level: 0.95,
154 })
155 }
156
157 fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
158 if !self.fitted {
159 return Err(ForecastError::NotFitted);
160 }
161
162 let ModelParams::Ses {
163 alpha,
164 ref mut level,
165 ..
166 } = self.params
167 else {
168 return Err(ForecastError::InvalidInput(
169 "unexpected model params variant".into(),
170 ));
171 };
172
173 *level = alpha * value + (1.0 - alpha) * *level;
174 self.last_ts = timestamp;
175 self.n_points += 1;
176 Ok(())
177 }
178
179 fn model_type(&self) -> ModelType {
180 ModelType::Ses
181 }
182
183 fn params(&self) -> &ModelParams {
184 &self.params
185 }
186}
187
188impl crate::forecast::storage::ModelStore for SesModel {}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn ses_constant_series() {
196 let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
197 let vals = vec![42.0; 100];
198 let mut model = SesModel::new(None);
199 model.fit(&ts, &vals).unwrap();
200 let result = model.predict(10).unwrap();
201 for &v in &result.values {
202 assert!((v - 42.0).abs() < 1e-6);
203 }
204 }
205
206 #[test]
207 fn ses_trending_series() {
208 let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
209 let vals: Vec<f64> = (0..100).map(|i| i as f64 * 2.0).collect();
210 let mut model = SesModel::new(Some(0.8));
211 model.fit(&ts, &vals).unwrap();
212 let result = model.predict(5).unwrap();
213 let last = vals.last().unwrap();
215 assert!((result.values[0] - last).abs() < 20.0);
216 }
217
218 #[test]
219 fn ses_auto_alpha() {
220 let ts: Vec<i64> = (0..50).map(|i| i * 1_000_000_000).collect();
221 let vals: Vec<f64> = (0..50).map(|i| (i as f64).sin() * 10.0 + 50.0).collect();
222 let mut model = SesModel::new(None);
223 model.fit(&ts, &vals).unwrap();
224 let ModelParams::Ses { alpha, .. } = model.params() else {
225 panic!()
226 };
227 assert!(*alpha > 0.0 && *alpha <= 1.0);
228 }
229
230 #[test]
231 fn ses_update() {
232 let ts: Vec<i64> = (0..10).map(|i| i * 1_000_000_000).collect();
233 let vals = vec![10.0; 10];
234 let mut model = SesModel::new(Some(0.5));
235 model.fit(&ts, &vals).unwrap();
236
237 model.update(10_000_000_000, 100.0).unwrap();
239 let ModelParams::Ses { level, .. } = model.params() else {
240 panic!()
241 };
242 assert!(*level > 10.0 && *level < 100.0);
243 }
244
245 #[test]
246 fn ses_confidence_intervals_widen() {
247 let ts: Vec<i64> = (0..50).map(|i| i * 1_000_000_000).collect();
248 let vals: Vec<f64> = (0..50).map(|i| i as f64 + (i as f64 * 0.5).sin()).collect();
249 let mut model = SesModel::new(Some(0.3));
250 model.fit(&ts, &vals).unwrap();
251 let result = model.predict(10).unwrap();
252 let width_1 = result.confidence_upper[0] - result.confidence_lower[0];
254 let width_10 = result.confidence_upper[9] - result.confidence_lower[9];
255 assert!(width_10 > width_1);
256 }
257
258 #[test]
259 fn ses_insufficient_data() {
260 let mut model = SesModel::new(None);
261 assert!(model.fit(&[0], &[1.0]).is_err());
262 }
263
264 #[test]
265 fn ses_not_fitted() {
266 let model = SesModel::new(None);
267 assert!(model.predict(10).is_err());
268 }
269
270 #[test]
271 fn metrics_recorded_on_fit_predict() {
272 let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
276 let vals: Vec<f64> = (0..100).map(|i| 50.0 + (i as f64) * 0.1).collect();
277 let mut model = SesModel::new(Some(0.3));
278 model.fit(&ts, &vals).unwrap();
279 let result = model.predict(10).unwrap();
280 assert_eq!(result.values.len(), 10);
281 }
283}