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::{one_step_mse, residuals, valid_level, with_intervals, SmoothingForecast};
19
20/// Result of simple exponential smoothing at each time step.
21#[derive(Debug, Clone)]
22pub struct SesResult {
23    /// Smoothed values.
24    pub smoothed: Vec<f64>,
25    /// One-step-ahead forecast for the next period.
26    pub forecast: f64,
27    /// One-step-ahead in-sample forecasts: `fitted[t] = smoothed[t − 1]`, and
28    /// `fitted[0]` the initial level `data[0]`.
29    pub fitted: Vec<f64>,
30    /// The smoothing constant α.
31    pub alpha: f64,
32}
33
34impl SesResult {
35    /// The index of the first one-step error the smoother learns from: before
36    /// it the fitted value is the initialisation.
37    pub const FIRST_ERROR: usize = 1;
38
39    /// `data[t] − fitted[t]`, `None` at the initialisation.
40    pub fn residuals(&self, data: &[f64]) -> Vec<Option<f64>> {
41        residuals(data, &self.fitted, Self::FIRST_ERROR)
42    }
43
44    /// Forecasts 1..=`horizon` steps ahead with the prediction interval of
45    /// ETS(A,N,N) at two-sided `level` ([module documentation](super#prediction-intervals)); `data` is the
46    /// series smoothed (for σ). `None` when `level` is not in (0, 1).
47    pub fn forecasts(
48        &self,
49        data: &[f64],
50        horizon: usize,
51        level: f64,
52    ) -> Option<Vec<SmoothingForecast>> {
53        if !valid_level(level) {
54            return None;
55        }
56        let alpha = self.alpha;
57        let c = move |_: usize| alpha;
58        Some(with_intervals(
59            horizon,
60            level,
61            one_step_mse(data, &self.fitted, Self::FIRST_ERROR),
62            Some(&c),
63            |_| self.forecast,
64        ))
65    }
66}
67
68/// Simple Exponential Smoothing.
69pub struct SimpleExponentialSmoothing {
70    alpha: f64,
71}
72
73impl SimpleExponentialSmoothing {
74    /// Creates a new SES smoother.
75    ///
76    /// # Parameters
77    /// - `alpha`: smoothing constant, must be in (0, 1)
78    ///
79    /// Returns `None` if alpha is out of range or non-finite.
80    pub fn new(alpha: f64) -> Option<Self> {
81        if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
82            return None;
83        }
84        Some(Self { alpha })
85    }
86
87    /// The smoother whose α minimises the sum of squared one-step errors on
88    /// `data` (from [`SesResult::FIRST_ERROR`]; [module documentation](super#estimating-the-constants)).
89    ///
90    /// Returns `None` with fewer than 2 values or a non-finite one.
91    ///
92    /// ```
93    /// use u_analytics::smoothing::SimpleExponentialSmoothing;
94    ///
95    /// let data = [10.0, 12.0, 11.0, 13.0, 12.5, 13.5, 13.0, 14.0];
96    /// let ses = SimpleExponentialSmoothing::fit(&data).unwrap();
97    /// assert!(ses.alpha() > 0.0 && ses.alpha() < 1.0);
98    /// ```
99    pub fn fit(data: &[f64]) -> Option<Self> {
100        if data.len() < 2 || data.iter().any(|v| !v.is_finite()) {
101            return None;
102        }
103        let c = super::estimate::least_squares(&[None], |c| {
104            let r = Self { alpha: c[0] }
105                .smooth(data)
106                .expect("data is not empty");
107            super::estimate::one_step_sse(data, &r.fitted, SesResult::FIRST_ERROR)
108        });
109        Self::new(c[0])
110    }
111
112    /// Returns the smoothing constant α.
113    pub fn alpha(&self) -> f64 {
114        self.alpha
115    }
116
117    /// Applies SES to the given data.
118    ///
119    /// Returns `None` if data is empty.
120    pub fn smooth(&self, data: &[f64]) -> Option<SesResult> {
121        if data.is_empty() {
122            return None;
123        }
124
125        let mut smoothed = Vec::with_capacity(data.len());
126        let mut fitted = Vec::with_capacity(data.len());
127        let mut s = data[0];
128        fitted.push(s);
129        smoothed.push(s);
130
131        for &x in &data[1..] {
132            fitted.push(s);
133            s = self.alpha * x + (1.0 - self.alpha) * s;
134            smoothed.push(s);
135        }
136
137        let forecast = s;
138        Some(SesResult {
139            smoothed,
140            forecast,
141            fitted,
142            alpha: self.alpha,
143        })
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_ses_basic() {
153        let ses = SimpleExponentialSmoothing::new(0.3).unwrap();
154        let data = [10.0, 12.0, 13.0, 11.0, 14.0];
155        let result = ses.smooth(&data).unwrap();
156
157        assert_eq!(result.smoothed.len(), 5);
158        // S1 = 10
159        assert!((result.smoothed[0] - 10.0).abs() < 1e-10);
160        // S2 = 0.3*12 + 0.7*10 = 10.6
161        assert!((result.smoothed[1] - 10.6).abs() < 1e-10);
162    }
163
164    #[test]
165    fn test_ses_constant_series() {
166        let ses = SimpleExponentialSmoothing::new(0.5).unwrap();
167        let data = [5.0; 10];
168        let result = ses.smooth(&data).unwrap();
169
170        for &v in &result.smoothed {
171            assert!((v - 5.0).abs() < 1e-10);
172        }
173        assert!((result.forecast - 5.0).abs() < 1e-10);
174    }
175
176    #[test]
177    fn test_ses_alpha_effect() {
178        // Higher alpha → more responsive to recent data
179        let data = [10.0, 20.0, 10.0];
180
181        let low = SimpleExponentialSmoothing::new(0.1).unwrap();
182        let high = SimpleExponentialSmoothing::new(0.9).unwrap();
183
184        let r_low = low.smooth(&data).unwrap();
185        let r_high = high.smooth(&data).unwrap();
186
187        // After step-up, high alpha should be closer to 20
188        assert!(r_high.smoothed[1] > r_low.smoothed[1]);
189    }
190
191    #[test]
192    fn test_ses_single_point() {
193        let ses = SimpleExponentialSmoothing::new(0.5).unwrap();
194        let result = ses.smooth(&[42.0]).unwrap();
195        assert_eq!(result.smoothed.len(), 1);
196        assert!((result.forecast - 42.0).abs() < 1e-10);
197    }
198
199    #[test]
200    fn test_ses_empty() {
201        let ses = SimpleExponentialSmoothing::new(0.5).unwrap();
202        assert!(ses.smooth(&[]).is_none());
203    }
204
205    #[test]
206    fn test_ses_invalid_alpha() {
207        assert!(SimpleExponentialSmoothing::new(0.0).is_none());
208        assert!(SimpleExponentialSmoothing::new(1.0).is_none());
209        assert!(SimpleExponentialSmoothing::new(-0.1).is_none());
210        assert!(SimpleExponentialSmoothing::new(f64::NAN).is_none());
211    }
212}