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
//! ADIDA (Aggregate-Disaggregate Intermittent Demand Approach) leaf.
//!
//! ADIDA (Nikolopoulos, Syntetos, Boylan, Petropoulos, Assimakopoulos,
//! 2011) reduces intermittency by aggregating the raw series into
//! coarser buckets before forecasting. On a series with 60 % zeros at
//! daily granularity, aggregating into weekly buckets (k=7) turns
//! most weeks non-zero — a continuous-scale SES then works better than
//! any per-period intermittent method.
//!
//! Update rule:
//! - Buffer incoming observations. When buffer reaches size `k`, sum
//! into one aggregated observation, apply SES, reset buffer.
//! - Forecast per period = `aggregated_ema / k`.
//!
//! At forecast time the horizon-h aggregate prediction is `aggregated_ema`
//! (constant across horizons); disaggregation is the "equal-weights"
//! rule (spread evenly across k buckets — the simplest of the ADIDA
//! disaggregation strategies).
//!
//! Bucket size `k` typically matches the natural seasonal / demand
//! cadence (7 for daily-with-weekly-demand, 4 or 12 for monthly, etc).
//! IMAPA (see `imapa.rs`) is the meta-ensemble across multiple k values.
use crate::models::laplace::dist::Gaussian;
use crate::models::laplace::leaf::Leaf;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AdidaLeaf {
alpha: f64,
aggregation_period: usize,
buffer_sum: f64,
buffer_count: usize,
aggregated_ema: f64,
initialized: bool,
n: usize,
ss: f64,
mean_resid: f64,
label: String,
}
impl AdidaLeaf {
/// `alpha`: SES rate on the aggregated series. `k`: bucket size.
pub fn new(alpha: f64, k: usize) -> Self {
let k = k.max(1);
let label = format!("adida{k}");
Self {
alpha: alpha.clamp(1e-3, 1.0 - 1e-3),
aggregation_period: k,
buffer_sum: 0.0,
buffer_count: 0,
aggregated_ema: 0.0,
initialized: false,
n: 0,
ss: 0.0,
mean_resid: 0.0,
label,
}
}
fn sigma(&self) -> f64 {
if self.n < 2 {
return 1.0;
}
(self.ss / (self.n as f64 - 1.0)).sqrt().max(1e-9)
}
fn point(&self) -> f64 {
if !self.initialized {
return 0.0;
}
// Per-period forecast is the aggregated EMA spread evenly.
self.aggregated_ema / self.aggregation_period as f64
}
}
impl Leaf for AdidaLeaf {
fn name(&self) -> &'static str {
// Leak the label so the trait's &'static str contract is honoured.
// O(unique k values) leaks per process — fine.
Box::leak(self.label.clone().into_boxed_str())
}
fn predict(&self, horizon: usize) -> Vec<Gaussian> {
let point = self.point();
let base = self.sigma();
(1..=horizon)
.map(|h| Gaussian::new(point, base * (h as f64).sqrt()))
.collect()
}
fn observe(&mut self, y: f64) {
let predicted = self.point();
let resid = y - predicted;
self.n += 1;
let delta = resid - self.mean_resid;
self.mean_resid += delta / self.n as f64;
self.ss += delta * (resid - self.mean_resid);
// Accumulate into the current bucket.
self.buffer_sum += y;
self.buffer_count += 1;
if self.buffer_count >= self.aggregation_period {
// Bucket full — treat sum as one aggregated observation.
if !self.initialized {
self.aggregated_ema = self.buffer_sum;
self.initialized = true;
} else {
self.aggregated_ema =
self.alpha * self.buffer_sum + (1.0 - self.alpha) * self.aggregated_ema;
}
self.buffer_sum = 0.0;
self.buffer_count = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adida_aggregates_reduce_intermittency() {
// 80 obs at daily granularity: 10 units every Monday, 0 else.
// ADIDA at k=7 sees weekly aggregate = 10 → forecast/period = 10/7.
let mut adida = AdidaLeaf::new(0.3, 7);
for _ in 0..80 {
adida.observe(10.0);
for _ in 0..6 {
adida.observe(0.0);
}
}
let point = adida.predict(1)[0].mean;
let expected = 10.0 / 7.0;
assert!(
(point - expected).abs() < 0.5,
"adida k=7 expected ~{expected:.3}, got {point:.3}"
);
}
#[test]
fn adida_k1_matches_plain_ses() {
// k=1 → aggregation is no-op → equivalent to SES on raw series.
let mut adida = AdidaLeaf::new(0.3, 1);
for y in [1.0, 2.0, 3.0, 4.0, 5.0] {
adida.observe(y);
}
// Not testing exact SES here — just that it converges to a
// reasonable value in the observed range.
let point = adida.predict(1)[0].mean;
assert!(
(1.0..=5.0).contains(&point),
"k=1 adida should be in-range, got {point}"
);
}
}