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
//! Theta-method leaf — port of skaters' `theta` transform.
//!
//! The Theta method (Assimakopoulos & Nikolopoulos 2000) is one of the
//! strongest simple univariate forecasters — best in M3, near-best in
//! M4. Skaters ships it as a `theta(alpha)` transform in three variants
//! `α ∈ {0.05, 0.1, 0.3}`. This is the streaming leaf port.
//!
//! **Model:** SES level with a half-OLS-slope drift correction. At each
//! step,
//!
//! ```text
//! forecast_t = level_{t-1} + slope_{t-1} / 2
//! level_t = α y_t + (1 - α) level_{t-1}
//! slope_t = OLS slope of y on t through step t
//! ```
//!
//! At horizon `h` the mean is `level_t + h · slope_t / 2` (linear
//! extrapolation of the half-slope). Variance is tracked as an EWMA of
//! residual² and grows with `√h` across horizons.
//!
//! PR #3 of #180.
use super::super::dist::Gaussian;
use super::super::leaf::Leaf;
/// SES + half-OLS-slope leaf with EWMA residual variance.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThetaLeaf {
alpha: f64,
// SES level.
level: f64,
initialized: bool,
// Running OLS accumulators for `y ~ a + b · t`.
n: f64,
sum_t: f64,
sum_t2: f64,
sum_y: f64,
sum_ty: f64,
// Current OLS slope estimate (updated after each observe).
slope: f64,
// EWMA of squared residual for the variance channel.
var_alpha: f64,
var: f64,
n_obs: usize,
}
impl ThetaLeaf {
/// Skaters ships α ∈ {0.05, 0.1, 0.3}. Use these for the standard
/// pool; other values are legal.
pub fn new(alpha: f64) -> Self {
Self {
alpha: alpha.clamp(1e-4, 0.999),
level: 0.0,
initialized: false,
n: 0.0,
sum_t: 0.0,
sum_t2: 0.0,
sum_y: 0.0,
sum_ty: 0.0,
slope: 0.0,
// Residual-variance EWMA rate — matches the terminal leaf's
// default, effective memory ~33 obs.
var_alpha: 0.03,
var: 0.0,
n_obs: 0,
}
}
}
impl Leaf for ThetaLeaf {
fn name(&self) -> &'static str {
"theta"
}
fn predict(&self, horizon: usize) -> Vec<Gaussian> {
let sigma_one = if self.var.is_finite() && self.var > 0.0 {
self.var.sqrt()
} else {
1.0
};
(1..=horizon)
.map(|h| {
let mean = self.level + (h as f64) * self.slope / 2.0;
// Variance grows with √h for a random-walk residual assumption.
let sigma = (sigma_one * (h as f64).sqrt()).max(1e-9);
Gaussian::new(mean, sigma)
})
.collect()
}
#[inline]
fn predict_one(&self) -> Gaussian {
let sigma_one = if self.var.is_finite() && self.var > 0.0 {
self.var.sqrt()
} else {
1.0
};
Gaussian::new(self.level + self.slope / 2.0, sigma_one.max(1e-9))
}
fn observe(&mut self, y: f64) {
if !y.is_finite() {
return;
}
if !self.initialized {
self.level = y;
self.initialized = true;
self.n = 1.0;
self.sum_t = 1.0;
self.sum_t2 = 1.0;
self.sum_y = y;
self.sum_ty = y;
self.slope = 0.0;
self.n_obs = 1;
return;
}
// Compute the one-step forecast made BEFORE folding y into the
// state — its residual drives the variance EWMA.
let forecast = self.level + self.slope / 2.0;
let residual = y - forecast;
if residual.is_finite() {
self.n_obs += 1;
let n = self.n_obs as f64;
let a = self.var_alpha.max(1.0 / n);
self.var = (1.0 - a) * self.var + a * residual * residual;
}
// SES update on level.
self.level = self.alpha * y + (1.0 - self.alpha) * self.level;
// Running OLS on (t, y). t = current step counter.
self.n += 1.0;
let t = self.n;
self.sum_t += t;
self.sum_t2 += t * t;
self.sum_y += y;
self.sum_ty += t * y;
let denom = self.n * self.sum_t2 - self.sum_t * self.sum_t;
self.slope = if denom.abs() > 1e-12 {
(self.n * self.sum_ty - self.sum_t * self.sum_y) / denom
} else {
0.0
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracks_linear_trend_via_ols_slope() {
let mut t = ThetaLeaf::new(0.1);
// y = 3 * step + noise-free
for step in 1..=200 {
t.observe(step as f64 * 3.0);
}
// Half-slope drift is applied to the forecast: expect the slope
// component to be very close to 3.0.
assert!(
(t.slope - 3.0).abs() < 0.2,
"OLS slope {:.4} not near true 3.0",
t.slope
);
// Level should track the last value tightly.
assert!(
(t.level - 600.0).abs() < 60.0,
"SES level {:.2} not near 600 (last obs)",
t.level
);
}
#[test]
fn multi_horizon_variance_grows_with_sqrt_h() {
let mut t = ThetaLeaf::new(0.1);
// Zero-mean random-walk-like path.
for i in 1..=500 {
let z = ((i as f64 * 3.111).sin() * 43758.5453).fract() - 0.5;
t.observe(z);
}
let g = t.predict(4);
// sqrt-h scaling: σ_4 ≈ 2 σ_1.
let r = g[3].std / g[0].std;
assert!(
(r - 2.0).abs() < 0.01,
"σ_4/σ_1 = {r:.3} not near 2.0 (√h scaling)"
);
}
#[test]
fn nan_is_ignored() {
let mut t = ThetaLeaf::new(0.1);
for step in 1..=50 {
t.observe(step as f64);
}
let before = t.slope;
t.observe(f64::NAN);
t.observe(f64::INFINITY);
assert_eq!(t.slope, before);
}
}