Skip to main content

chronix_analytics/forecast/
result.rs

1//! Forecast result types and model metadata.
2
3/// Type of forecast model.
4#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5pub enum ModelType {
6    /// Simple Exponential Smoothing.
7    Ses,
8    /// Holt's Linear Trend (double exponential smoothing).
9    HoltLinear,
10    /// Holt-Winters (triple exponential smoothing with seasonality).
11    HoltWinters,
12    /// ARIMA(p,d,q) — autoregressive integrated moving average.
13    Arima,
14    /// SARIMA(p,d,q)(P,D,Q)m — seasonal ARIMA.
15    Sarima,
16    /// Ordinary Least Squares linear regression.
17    LinearRegression,
18    /// Custom plugin model identified by name.
19    Custom(String),
20}
21
22/// Forecast result with predictions and confidence intervals.
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
24pub struct ForecastResult {
25    /// Predicted values.
26    pub values: Vec<f64>,
27    /// Predicted timestamps (nanoseconds).
28    pub timestamps: Vec<i64>,
29    /// Lower confidence bound.
30    pub confidence_lower: Vec<f64>,
31    /// Upper confidence bound.
32    pub confidence_upper: Vec<f64>,
33    /// Confidence level (e.g. 0.95).
34    pub confidence_level: f64,
35}
36
37/// Fitted model parameters — variant per model type.
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39pub enum ModelParams {
40    /// Simple Exponential Smoothing parameters.
41    Ses {
42        /// Smoothing factor α ∈ (0, 1).
43        alpha: f64,
44        /// Last smoothed level.
45        level: f64,
46        /// Standard deviation of in-sample residuals.
47        residual_std: f64,
48    },
49    /// Holt's Linear Trend parameters.
50    HoltLinear {
51        /// Level smoothing factor α.
52        alpha: f64,
53        /// Trend smoothing factor β.
54        beta: f64,
55        /// Last smoothed level.
56        level: f64,
57        /// Last smoothed trend.
58        trend: f64,
59        /// Damping factor ϕ (1.0 = undamped).
60        phi: f64,
61        /// Standard deviation of in-sample residuals.
62        residual_std: f64,
63    },
64    /// Holt-Winters (triple exponential smoothing) parameters.
65    HoltWinters {
66        /// Level smoothing factor α.
67        alpha: f64,
68        /// Trend smoothing factor β.
69        beta: f64,
70        /// Seasonal smoothing factor γ.
71        gamma: f64,
72        /// Seasonal period length (number of observations per cycle).
73        period: usize,
74        /// Last smoothed level.
75        level: f64,
76        /// Last smoothed trend.
77        trend: f64,
78        /// Seasonal indices (one per period position).
79        seasonal: Vec<f64>,
80        /// Whether multiplicative (true) or additive (false) seasonality is used.
81        multiplicative: bool,
82        /// Standard deviation of in-sample residuals.
83        residual_std: f64,
84        /// Phase offset into the seasonal vector for the first prediction step.
85        /// Set to `n_train % period` during fit so predictions continue the cycle.
86        #[serde(default)]
87        seasonal_phase: usize,
88    },
89    /// ARIMA(p,d,q) parameters.
90    Arima {
91        /// Autoregressive order.
92        p: usize,
93        /// Differencing order.
94        d: usize,
95        /// Moving average order.
96        q: usize,
97        /// Autoregressive coefficients φ₁…φₚ.
98        ar_coeffs: Vec<f64>,
99        /// Moving average coefficients θ₁…θ_q.
100        ma_coeffs: Vec<f64>,
101        /// Mean of the differenced series, added back to every forecast.
102        ///
103        /// Zero when the model carries no constant — see
104        /// [`ArimaModel`](crate::forecast::ArimaModel) for when one is
105        /// included.
106        constant: f64,
107        /// Standard deviation of in-sample residuals.
108        residual_std: f64,
109        /// Effective AR order after Burg estimation.
110        /// May be less than `p` if the algorithm terminated early
111        /// due to near-constant input data.
112        #[serde(default)]
113        effective_ar_order: Option<usize>,
114    },
115    /// Seasonal ARIMA (p,d,q)(P,D,Q)m parameters.
116    Sarima {
117        /// Non-seasonal AR order.
118        p: usize,
119        /// Non-seasonal differencing order.
120        d: usize,
121        /// Non-seasonal MA order.
122        q: usize,
123        /// Seasonal AR order P.
124        sp: usize,
125        /// Seasonal differencing order D.
126        sd: usize,
127        /// Seasonal MA order Q.
128        sq: usize,
129        /// Seasonal period m.
130        m: usize,
131        /// Non-seasonal AR coefficients.
132        ar_coeffs: Vec<f64>,
133        /// Non-seasonal MA coefficients.
134        ma_coeffs: Vec<f64>,
135        /// Seasonal AR coefficients Φ₁…Φ_P.
136        sar_coeffs: Vec<f64>,
137        /// Seasonal MA coefficients Θ₁…Θ_Q.
138        sma_coeffs: Vec<f64>,
139        /// Mean of the fully differenced series, added back to every forecast.
140        constant: f64,
141        /// Standard deviation of in-sample residuals.
142        residual_std: f64,
143    },
144    /// Linear regression parameters.
145    LinearRegression {
146        /// Regression slope (change per unit index).
147        slope: f64,
148        /// Regression intercept.
149        intercept: f64,
150        /// Coefficient of determination R².
151        r_squared: f64,
152        /// Standard deviation of in-sample residuals.
153        residual_std: f64,
154        /// Start timestamp of training data (nanoseconds).
155        start_ts: i64,
156        /// Observation interval (nanoseconds).
157        interval_ns: i64,
158    },
159    /// Opaque parameters for a custom plugin model.
160    Custom {
161        /// Plugin model name.
162        name: String,
163        /// Opaque serialized parameter data (interpreted by the plugin).
164        data: Vec<u8>,
165    },
166}
167
168impl ForecastResult {
169    /// Rescale the prediction interval to a different confidence level.
170    ///
171    /// Every model in this crate builds a **symmetric normal** interval —
172    /// `value ± z·σ_h`, with `σ_h` whatever the model's horizon-dependent
173    /// error scale is — so changing the level is exactly a change of `z`, and
174    /// this rescaling is not an approximation of the model's own answer at
175    /// that level: it *is* that answer.
176    ///
177    /// That is only true because the interval is symmetric and normal. It is
178    /// not true of the empirical residual quantiles from
179    /// [`QuantileForecaster`](crate::forecast::QuantileForecaster), which is
180    /// where to go when the residuals are skewed or the tails matter more than
181    /// the shape.
182    ///
183    /// # Errors
184    ///
185    /// [`ForecastError::InvalidParams`](crate::forecast::ForecastError::InvalidParams)
186    /// when `level` is not in `(0, 1)`, or when the result's own
187    /// `confidence_level` is not, which would make the ratio meaningless.
188    pub fn with_confidence(
189        mut self,
190        level: f64,
191    ) -> Result<Self, crate::forecast::error::ForecastError> {
192        use crate::forecast::error::ForecastError;
193        use crate::forecast::util::z_for_confidence;
194
195        let invalid = |name: &'static str, value: f64| ForecastError::InvalidParams {
196            name,
197            value: value.to_string(),
198            reason: "confidence level must be strictly between 0 and 1",
199        };
200        let target = z_for_confidence(level).ok_or_else(|| invalid("level", level))?;
201        let current = z_for_confidence(self.confidence_level)
202            .ok_or_else(|| invalid("confidence_level", self.confidence_level))?;
203
204        let ratio = target / current;
205        for h in 0..self.values.len() {
206            let (Some(lo), Some(hi)) = (
207                self.confidence_lower.get(h).copied(),
208                self.confidence_upper.get(h).copied(),
209            ) else {
210                continue;
211            };
212            let point = self.values[h];
213            self.confidence_lower[h] = point - (point - lo) * ratio;
214            self.confidence_upper[h] = point + (hi - point) * ratio;
215        }
216        self.confidence_level = level;
217        Ok(self)
218    }
219}
220
221#[cfg(test)]
222#[allow(clippy::unwrap_used, clippy::expect_used)]
223mod tests {
224    use super::*;
225
226    fn sample() -> ForecastResult {
227        ForecastResult {
228            values: vec![10.0, 20.0],
229            timestamps: vec![1, 2],
230            confidence_lower: vec![8.0, 16.0],
231            confidence_upper: vec![12.0, 24.0],
232            confidence_level: 0.95,
233        }
234    }
235
236    #[test]
237    fn normal_quantile_matches_the_textbook_values() {
238        use crate::forecast::util::normal_quantile;
239        for (p, expected) in [
240            (0.5, 0.0),
241            (0.975, 1.959_963_984_540_054),
242            (0.995, 2.575_829_303_548_901),
243            (0.9, 1.281_551_565_544_6),
244            (0.025, -1.959_963_984_540_054),
245            // Deep in the tail, where the central rational fit is not used.
246            (1e-6, -4.753_424_308_822_899),
247        ] {
248            let got = normal_quantile(p);
249            assert!(
250                (got - expected).abs() < 1e-6,
251                "Φ⁻¹({p}) = {got}, expected {expected}"
252            );
253        }
254        assert!(normal_quantile(0.0).is_infinite());
255        assert!(normal_quantile(1.0).is_infinite());
256        assert!(normal_quantile(-0.1).is_nan());
257        assert!(normal_quantile(1.1).is_nan());
258    }
259
260    #[test]
261    fn z_for_confidence_is_the_two_sided_multiplier() {
262        use crate::forecast::util::z_for_confidence;
263        assert!((z_for_confidence(0.95).unwrap() - 1.959_963_98).abs() < 1e-6);
264        assert!((z_for_confidence(0.99).unwrap() - 2.575_829_30).abs() < 1e-6);
265        assert!((z_for_confidence(0.80).unwrap() - 1.281_551_57).abs() < 1e-6);
266        assert!(z_for_confidence(0.0).is_none());
267        assert!(z_for_confidence(1.0).is_none());
268    }
269
270    #[test]
271    fn widening_the_interval_keeps_the_point_forecast() {
272        let widened = sample().with_confidence(0.99).unwrap();
273        assert_eq!(widened.values, vec![10.0, 20.0]);
274        assert_eq!(widened.confidence_level, 0.99);
275        // The half-width scales by z(0.99)/z(0.95) ≈ 1.31417. Taken from
276        // `z_for_confidence` rather than from the textbook constants: this
277        // test is about the rescaling, and the accuracy of Φ⁻¹ itself is
278        // pinned separately above.
279        use crate::forecast::util::z_for_confidence;
280        let ratio = z_for_confidence(0.99).unwrap() / z_for_confidence(0.95).unwrap();
281        assert!((ratio - 1.314_17).abs() < 1e-4, "ratio = {ratio}");
282        assert!((widened.confidence_upper[0] - (10.0 + 2.0 * ratio)).abs() < 1e-9);
283        assert!((widened.confidence_lower[0] - (10.0 - 2.0 * ratio)).abs() < 1e-9);
284        assert!((widened.confidence_upper[1] - (20.0 + 4.0 * ratio)).abs() < 1e-9);
285    }
286
287    #[test]
288    fn narrowing_and_widening_round_trip() {
289        let round = sample()
290            .with_confidence(0.5)
291            .unwrap()
292            .with_confidence(0.95)
293            .unwrap();
294        for h in 0..2 {
295            assert!((round.confidence_lower[h] - sample().confidence_lower[h]).abs() < 1e-9);
296            assert!((round.confidence_upper[h] - sample().confidence_upper[h]).abs() < 1e-9);
297        }
298    }
299
300    #[test]
301    fn an_out_of_range_level_is_rejected() {
302        assert!(sample().with_confidence(0.0).is_err());
303        assert!(sample().with_confidence(1.0).is_err());
304        assert!(sample().with_confidence(-0.5).is_err());
305    }
306}