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