Skip to main content

u_analytics/smoothing/
ses.rs

1//! Simple Exponential Smoothing (SES).
2//!
3//! Level-only smoothing for stationary time series (no trend, no seasonality).
4//!
5//! # Algorithm
6//!
7//! ```text
8//! S_1 = x_1
9//! S_t = α x_t + (1 - α) S_{t-1}
10//! ```
11//!
12//! where α ∈ (0, 1) is the smoothing constant.
13//!
14//! # Reference
15//!
16//! Brown, R.G. (1956). *Exponential Smoothing for Predicting Demand*.
17
18use super::forecast::{
19    first_passage, one_step_mse, passage_arguments, residuals, valid_level, with_intervals,
20    FirstPassage, InnovationWeights, SmoothingForecast,
21};
22
23/// Result of simple exponential smoothing at each time step.
24#[derive(Debug, Clone)]
25pub struct SesResult {
26    /// Smoothed values.
27    pub smoothed: Vec<f64>,
28    /// One-step-ahead forecast for the next period.
29    pub forecast: f64,
30    /// One-step-ahead in-sample forecasts: `fitted[t] = smoothed[t − 1]`, and
31    /// `fitted[0]` the initial level `data[0]`.
32    pub fitted: Vec<f64>,
33    /// The smoothing constant α.
34    pub alpha: f64,
35}
36
37impl SesResult {
38    /// The index of the first one-step error the smoother learns from: before
39    /// it the fitted value is the initialisation.
40    pub const FIRST_ERROR: usize = 1;
41
42    /// `data[t] − fitted[t]`, `None` at the initialisation.
43    pub fn residuals(&self, data: &[f64]) -> Vec<Option<f64>> {
44        residuals(data, &self.fitted, Self::FIRST_ERROR)
45    }
46
47    /// Forecasts 1..=`horizon` steps ahead with the prediction interval of
48    /// ETS(A,N,N) at two-sided `level` ([module documentation](super#prediction-intervals)); `data` is the
49    /// series smoothed (for σ). `None` when `level` is not in (0, 1).
50    pub fn forecasts(
51        &self,
52        data: &[f64],
53        horizon: usize,
54        level: f64,
55    ) -> Option<Vec<SmoothingForecast>> {
56        if !valid_level(level) {
57            return None;
58        }
59        let weights = self.weights();
60        let c = move |j: usize| weights.c(j);
61        Some(with_intervals(
62            horizon,
63            level,
64            one_step_mse(data, &self.fitted, Self::FIRST_ERROR),
65            Some(&c),
66            |_| self.forecast,
67        ))
68    }
69    /// The innovation weights of the model: the SES weights `c_j = α`.
70    fn weights(&self) -> InnovationWeights {
71        InnovationWeights {
72            level: self.alpha,
73            slope: 0.0,
74            season: 0.0,
75            period: 0,
76        }
77    }
78
79    /// When the forecast total over steps 1, 2, … first reaches `threshold`
80    /// (non-zero; from below when positive, from above when negative), over
81    /// `paths` simulated futures of the fitted model seeded with `seed`, within
82    /// `horizon` steps -- with the `quantiles` (each in (0, 1]) of that time
83    /// ([`FirstPassage`]). `data` is the series smoothed (for σ). `None` when
84    /// an argument is outside those ranges or there is no one-step error to
85    /// estimate σ from.
86    #[allow(clippy::too_many_arguments)]
87    pub fn first_passage(
88        &self,
89        data: &[f64],
90        horizon: usize,
91        threshold: f64,
92        quantiles: &[f64],
93        paths: usize,
94        seed: u64,
95    ) -> Option<FirstPassage> {
96        if !passage_arguments(horizon, threshold, quantiles, paths) {
97            return None;
98        }
99        let mse = one_step_mse(data, &self.fitted, Self::FIRST_ERROR)?;
100        let means = vec![self.forecast; horizon];
101        Some(first_passage(
102            &means,
103            mse,
104            self.weights(),
105            threshold,
106            quantiles,
107            paths,
108            seed,
109        ))
110    }
111}
112
113/// Simple Exponential Smoothing.
114pub struct SimpleExponentialSmoothing {
115    alpha: f64,
116}
117
118impl SimpleExponentialSmoothing {
119    /// Creates a new SES smoother.
120    ///
121    /// # Parameters
122    /// - `alpha`: smoothing constant, must be in (0, 1)
123    ///
124    /// Returns `None` if alpha is out of range or non-finite.
125    pub fn new(alpha: f64) -> Option<Self> {
126        if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
127            return None;
128        }
129        Some(Self { alpha })
130    }
131
132    /// The smoother whose α minimises the sum of squared one-step errors on
133    /// `data` (from [`SesResult::FIRST_ERROR`]; [module documentation](super#estimating-the-constants)).
134    ///
135    /// Returns `None` with fewer than 2 values or a non-finite one.
136    ///
137    /// ```
138    /// use u_analytics::smoothing::SimpleExponentialSmoothing;
139    ///
140    /// let data = [10.0, 12.0, 11.0, 13.0, 12.5, 13.5, 13.0, 14.0];
141    /// let ses = SimpleExponentialSmoothing::fit(&data).unwrap();
142    /// assert!(ses.alpha() > 0.0 && ses.alpha() < 1.0);
143    /// ```
144    pub fn fit(data: &[f64]) -> Option<Self> {
145        if data.len() < 2 || data.iter().any(|v| !v.is_finite()) {
146            return None;
147        }
148        let c = super::estimate::least_squares(&[None], |c| {
149            let r = Self { alpha: c[0] }
150                .smooth(data)
151                .expect("data is not empty");
152            super::estimate::one_step_sse(data, &r.fitted, SesResult::FIRST_ERROR)
153        });
154        Self::new(c[0])
155    }
156
157    /// Returns the smoothing constant α.
158    pub fn alpha(&self) -> f64 {
159        self.alpha
160    }
161
162    /// Applies SES to the given data.
163    ///
164    /// Returns `None` if data is empty.
165    pub fn smooth(&self, data: &[f64]) -> Option<SesResult> {
166        if data.is_empty() {
167            return None;
168        }
169
170        let mut smoothed = Vec::with_capacity(data.len());
171        let mut fitted = Vec::with_capacity(data.len());
172        let mut s = data[0];
173        fitted.push(s);
174        smoothed.push(s);
175
176        for &x in &data[1..] {
177            fitted.push(s);
178            s = self.alpha * x + (1.0 - self.alpha) * s;
179            smoothed.push(s);
180        }
181
182        let forecast = s;
183        Some(SesResult {
184            smoothed,
185            forecast,
186            fitted,
187            alpha: self.alpha,
188        })
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_ses_basic() {
198        let ses = SimpleExponentialSmoothing::new(0.3).unwrap();
199        let data = [10.0, 12.0, 13.0, 11.0, 14.0];
200        let result = ses.smooth(&data).unwrap();
201
202        assert_eq!(result.smoothed.len(), 5);
203        // S1 = 10
204        assert!((result.smoothed[0] - 10.0).abs() < 1e-10);
205        // S2 = 0.3*12 + 0.7*10 = 10.6
206        assert!((result.smoothed[1] - 10.6).abs() < 1e-10);
207    }
208
209    #[test]
210    fn test_ses_constant_series() {
211        let ses = SimpleExponentialSmoothing::new(0.5).unwrap();
212        let data = [5.0; 10];
213        let result = ses.smooth(&data).unwrap();
214
215        for &v in &result.smoothed {
216            assert!((v - 5.0).abs() < 1e-10);
217        }
218        assert!((result.forecast - 5.0).abs() < 1e-10);
219    }
220
221    #[test]
222    fn test_ses_alpha_effect() {
223        // Higher alpha → more responsive to recent data
224        let data = [10.0, 20.0, 10.0];
225
226        let low = SimpleExponentialSmoothing::new(0.1).unwrap();
227        let high = SimpleExponentialSmoothing::new(0.9).unwrap();
228
229        let r_low = low.smooth(&data).unwrap();
230        let r_high = high.smooth(&data).unwrap();
231
232        // After step-up, high alpha should be closer to 20
233        assert!(r_high.smoothed[1] > r_low.smoothed[1]);
234    }
235
236    #[test]
237    fn test_ses_single_point() {
238        let ses = SimpleExponentialSmoothing::new(0.5).unwrap();
239        let result = ses.smooth(&[42.0]).unwrap();
240        assert_eq!(result.smoothed.len(), 1);
241        assert!((result.forecast - 42.0).abs() < 1e-10);
242    }
243
244    #[test]
245    fn test_ses_empty() {
246        let ses = SimpleExponentialSmoothing::new(0.5).unwrap();
247        assert!(ses.smooth(&[]).is_none());
248    }
249
250    #[test]
251    fn test_ses_invalid_alpha() {
252        assert!(SimpleExponentialSmoothing::new(0.0).is_none());
253        assert!(SimpleExponentialSmoothing::new(1.0).is_none());
254        assert!(SimpleExponentialSmoothing::new(-0.1).is_none());
255        assert!(SimpleExponentialSmoothing::new(f64::NAN).is_none());
256    }
257}