use crate::forecast::error::ForecastError;
use crate::forecast::result::{ForecastResult, ModelParams, ModelType};
use crate::forecast::traits::ForecastModel;
use crate::forecast::util::{median_interval, validate_input};
#[inline]
fn safe_div(x: f64, y: f64) -> f64 {
const EPS: f64 = 1e-10;
if y.abs() < EPS {
x / EPS.copysign(y)
} else {
x / y
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct HoltWintersModel {
alpha: Option<f64>,
beta: Option<f64>,
gamma: Option<f64>,
period: Option<usize>,
multiplicative: bool,
params: ModelParams,
last_ts: i64,
interval_ns: i64,
seasonal_phase: usize,
fitted: bool,
}
impl HoltWintersModel {
pub fn new(
alpha: Option<f64>,
beta: Option<f64>,
gamma: Option<f64>,
period: Option<usize>,
multiplicative: bool,
) -> Self {
Self {
alpha,
beta,
gamma,
period,
multiplicative,
params: ModelParams::HoltWinters {
alpha: 0.0,
beta: 0.0,
gamma: 0.0,
period: 0,
level: 0.0,
trend: 0.0,
seasonal: Vec::new(),
multiplicative: false,
residual_std: 0.0,
seasonal_phase: 0,
},
last_ts: 0,
interval_ns: 0,
seasonal_phase: 0,
fitted: false,
}
}
fn detect_period(values: &[f64]) -> usize {
crate::preprocess::detect_period(values, values.len() / 2).unwrap_or(1)
}
fn fit_non_seasonal(
&mut self,
timestamps: &[i64],
values: &[f64],
_start: std::time::Instant,
) -> Result<(), ForecastError> {
let alpha = self.alpha.unwrap_or(0.3);
let beta = self.beta.unwrap_or(0.05);
let mut level = values[0];
let mut trend = if values.len() > 1 {
values[1] - values[0]
} else {
0.0
};
let mut sse = 0.0;
let mut count = 0;
for &v in &values[1..] {
let forecast = level + trend;
let err = v - forecast;
sse += err * err;
count += 1;
let new_level = alpha * v + (1.0 - alpha) * (level + trend);
trend = beta * (new_level - level) + (1.0 - beta) * trend;
level = new_level;
}
let dof = (values.len() as f64 - 3.0).max(1.0);
let residual_std = if count > 0 { (sse / dof).sqrt() } else { 0.0 };
self.interval_ns = median_interval(timestamps);
self.last_ts = timestamps[timestamps.len() - 1];
self.seasonal_phase = 0;
self.params = ModelParams::HoltWinters {
alpha,
beta,
gamma: 0.0,
period: 1,
level,
trend,
seasonal: vec![if self.multiplicative { 1.0 } else { 0.0 }],
multiplicative: self.multiplicative,
residual_std,
seasonal_phase: 0,
};
self.fitted = true;
metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "holt_winters")
.record(_start.elapsed().as_secs_f64());
Ok(())
}
fn optimize_params(values: &[f64], period: usize, multiplicative: bool) -> (f64, f64, f64) {
let result = crate::forecast::optimizer::minimize_nelder_mead(
|p| Self::compute_mse(values, p[0], p[1], p[2], period, multiplicative),
&[0.3, 0.05, 0.1],
&[
crate::forecast::optimizer::Bound::new(0.01, 0.99),
crate::forecast::optimizer::Bound::new(0.001, 0.99),
crate::forecast::optimizer::Bound::new(0.01, 0.99),
],
500,
1e-8,
);
(
result.params[0].clamp(0.01, 0.99),
result.params[1].clamp(0.001, 0.99),
result.params[2].clamp(0.01, 0.99),
)
}
fn compute_mse(
values: &[f64],
alpha: f64,
beta: f64,
gamma: f64,
period: usize,
multiplicative: bool,
) -> f64 {
if values.len() < 2 * period {
return f64::INFINITY;
}
let (mut level, mut trend, mut seasonal) = Self::initialize(values, period, multiplicative);
let mut sse = 0.0;
#[allow(clippy::needless_range_loop)]
for i in period..values.len() {
let si = i % period;
let forecast = if multiplicative {
(level + trend) * seasonal[si]
} else {
level + trend + seasonal[si]
};
let err = values[i] - forecast;
sse += err * err;
let new_level = if multiplicative {
alpha * safe_div(values[i], seasonal[si]) + (1.0 - alpha) * (level + trend)
} else {
alpha * (values[i] - seasonal[si]) + (1.0 - alpha) * (level + trend)
};
let new_trend = beta * (new_level - level) + (1.0 - beta) * trend;
seasonal[si] = if multiplicative {
gamma * safe_div(values[i], new_level) + (1.0 - gamma) * seasonal[si]
} else {
gamma * (values[i] - new_level) + (1.0 - gamma) * seasonal[si]
};
level = new_level;
trend = new_trend;
}
sse / (values.len() - period) as f64
}
fn initialize(values: &[f64], period: usize, multiplicative: bool) -> (f64, f64, Vec<f64>) {
let level: f64 = values[..period].iter().sum::<f64>() / period as f64;
let trend = if values.len() >= 2 * period {
let mut sum = 0.0;
for i in 0..period {
sum += (values[period + i] - values[i]) / period as f64;
}
sum / period as f64
} else {
0.0
};
let seasonal: Vec<f64> = if multiplicative {
values[..period]
.iter()
.map(|&v| safe_div(v, level))
.collect()
} else {
values[..period].iter().map(|&v| v - level).collect()
};
(level, trend, seasonal)
}
}
#[must_use]
pub fn holt_winters_variance_factor(
alpha: f64,
beta: f64,
gamma: f64,
period: usize,
h: usize,
) -> f64 {
let m = period.max(1);
let mut acc = 1.0;
for j in 1..h {
let mut psi = alpha * (1.0 + beta * j as f64);
if j % m == 0 {
psi += gamma;
}
acc += psi * psi;
}
acc
}
impl ForecastModel for HoltWintersModel {
#[tracing::instrument(skip_all, level = "debug")]
fn fit(&mut self, timestamps: &[i64], values: &[f64]) -> Result<(), ForecastError> {
let _start = std::time::Instant::now();
validate_input(timestamps, values)?;
let mut period = self.period.unwrap_or_else(|| Self::detect_period(values));
if period >= 2 && values.len() < 2 * period {
tracing::warn!(
period,
n = values.len(),
"Insufficient data for seasonal Holt-Winters (need {}); \
falling back to non-seasonal model",
2 * period,
);
period = 1;
}
if period < 2 {
return self.fit_non_seasonal(timestamps, values, _start);
}
let (alpha, beta, gamma) = match (self.alpha, self.beta, self.gamma) {
(Some(a), Some(b), Some(g)) => (a, b, g),
_ => Self::optimize_params(values, period, self.multiplicative),
};
let (mut level, mut trend, mut seasonal) =
Self::initialize(values, period, self.multiplicative);
let mut sse = 0.0;
let mut count = 0;
#[allow(clippy::needless_range_loop)]
for i in period..values.len() {
let si = i % period;
let forecast = if self.multiplicative {
(level + trend) * seasonal[si]
} else {
level + trend + seasonal[si]
};
let err = values[i] - forecast;
sse += err * err;
count += 1;
let new_level = if self.multiplicative {
alpha * safe_div(values[i], seasonal[si]) + (1.0 - alpha) * (level + trend)
} else {
alpha * (values[i] - seasonal[si]) + (1.0 - alpha) * (level + trend)
};
let new_trend = beta * (new_level - level) + (1.0 - beta) * trend;
seasonal[si] = if self.multiplicative {
gamma * safe_div(values[i], new_level) + (1.0 - gamma) * seasonal[si]
} else {
gamma * (values[i] - new_level) + (1.0 - gamma) * seasonal[si]
};
level = new_level;
trend = new_trend;
}
let dof = (values.len() as f64 - period as f64 - 3.0).max(1.0);
let residual_std = if count > 0 { (sse / dof).sqrt() } else { 0.0 };
self.interval_ns = median_interval(timestamps);
self.last_ts = timestamps[timestamps.len() - 1];
self.seasonal_phase = values.len() % period;
self.params = ModelParams::HoltWinters {
alpha,
beta,
gamma,
period,
level,
trend,
seasonal,
multiplicative: self.multiplicative,
residual_std,
seasonal_phase: self.seasonal_phase,
};
self.fitted = true;
metrics::histogram!("chronix_forecast_fit_duration_seconds", "model_type" => "holt_winters").record(_start.elapsed().as_secs_f64());
Ok(())
}
#[tracing::instrument(skip_all, level = "debug")]
fn predict(&self, horizon: usize) -> Result<ForecastResult, ForecastError> {
let _start = std::time::Instant::now();
if !self.fitted {
return Err(ForecastError::NotFitted);
}
let ModelParams::HoltWinters {
alpha,
beta,
gamma,
level,
trend,
seasonal,
multiplicative,
period,
residual_std,
..
} = &self.params
else {
return Err(ForecastError::InvalidInput(
"unexpected model params variant".into(),
));
};
let z = 1.96;
let mut values = Vec::with_capacity(horizon);
let mut timestamps = Vec::with_capacity(horizon);
let mut lower = Vec::with_capacity(horizon);
let mut upper = Vec::with_capacity(horizon);
for h in 1..=horizon {
let ts = self
.last_ts
.saturating_add((h as i64).saturating_mul(self.interval_ns));
let si = (self.seasonal_phase + h - 1) % period;
let forecast = if *multiplicative {
(level + h as f64 * trend) * seasonal[si]
} else {
level + h as f64 * trend + seasonal[si]
};
let var_sum = holt_winters_variance_factor(*alpha, *beta, *gamma, *period, h);
let width = z * residual_std * var_sum.sqrt();
values.push(forecast);
timestamps.push(ts);
lower.push(forecast - width);
upper.push(forecast + width);
}
metrics::histogram!("chronix_forecast_predict_duration_seconds", "model_type" => "holt_winters").record(_start.elapsed().as_secs_f64());
Ok(ForecastResult {
values,
timestamps,
confidence_lower: lower,
confidence_upper: upper,
confidence_level: 0.95,
})
}
fn update(&mut self, timestamp: i64, value: f64) -> Result<(), ForecastError> {
if !self.fitted {
return Err(ForecastError::NotFitted);
}
let ModelParams::HoltWinters {
alpha,
beta,
gamma,
ref mut level,
ref mut trend,
ref mut seasonal,
multiplicative,
period,
..
} = self.params
else {
return Err(ForecastError::InvalidInput(
"unexpected model params variant".into(),
));
};
let elapsed = timestamp - self.last_ts;
let steps = (elapsed / self.interval_ns).max(1) as usize;
let si = (self.seasonal_phase + steps - 1) % period;
let new_level = if multiplicative {
alpha * safe_div(value, seasonal[si]) + (1.0 - alpha) * (*level + *trend)
} else {
alpha * (value - seasonal[si]) + (1.0 - alpha) * (*level + *trend)
};
*trend = beta * (new_level - *level) + (1.0 - beta) * *trend;
seasonal[si] = if multiplicative {
gamma * safe_div(value, new_level) + (1.0 - gamma) * seasonal[si]
} else {
gamma * (value - new_level) + (1.0 - gamma) * seasonal[si]
};
*level = new_level;
self.last_ts = timestamp;
self.seasonal_phase = (self.seasonal_phase + steps) % period;
Ok(())
}
fn model_type(&self) -> ModelType {
ModelType::HoltWinters
}
fn params(&self) -> &ModelParams {
&self.params
}
}
impl crate::forecast::storage::ModelStore for HoltWintersModel {}
#[cfg(test)]
mod tests {
use super::*;
fn seasonal_data(n: usize, period: usize) -> (Vec<i64>, Vec<f64>) {
let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
let vals: Vec<f64> = (0..n)
.map(|i| {
let trend = i as f64 * 0.5;
let season =
10.0 * (2.0 * std::f64::consts::PI * (i % period) as f64 / period as f64).sin();
trend + season + 50.0
})
.collect();
(ts, vals)
}
#[test]
fn hw_interval_matches_hyndman_psi_weights() {
let (ts, vals) = seasonal_data(120, 24);
let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(24), false);
model.fit(&ts, &vals).unwrap();
let ModelParams::HoltWinters { residual_std, .. } = model.params() else {
panic!()
};
let sigma = *residual_std;
assert!(sigma > 0.0);
let r = model.predict(24).unwrap();
let z = 1.96;
let half1 = r.confidence_upper[0] - r.values[0];
assert!(
(half1 - z * sigma).abs() < 1e-12,
"h=1 half-width {half1} != z*sigma {}",
z * sigma
);
let expected_factor = 11.929_6_f64;
let ratio = (r.confidence_upper[23] - r.values[23]) / half1;
assert!(
(ratio * ratio - expected_factor).abs() < 1e-9,
"h=24 variance ratio {} != {expected_factor}",
ratio * ratio
);
assert!(
(ratio - expected_factor.sqrt()).abs() < 1e-9,
"h=24 width ratio {ratio} != {}",
expected_factor.sqrt()
);
}
#[test]
fn hw_variance_factor_uses_fitted_gamma_at_seasonal_boundary() {
let f = holt_winters_variance_factor(0.5, 0.1, 0.3, 4, 5);
let mut expected = 1.0;
for j in 1..5 {
let mut psi = 0.5 * (1.0 + 0.1 * j as f64);
if j % 4 == 0 {
psi += 0.3;
}
expected += psi * psi;
}
assert!((f - expected).abs() < 1e-12);
assert!((f - holt_winters_variance_factor(0.5, 0.1, 0.0, 4, 5)).abs() > 0.1);
}
#[test]
fn hw_additive_seasonal() {
let (ts, vals) = seasonal_data(200, 24);
let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(24), false);
model.fit(&ts, &vals).unwrap();
let result = model.predict(24).unwrap();
assert_eq!(result.values.len(), 24);
let range = result.values.iter().copied().fold(f64::INFINITY, f64::min);
let max = result
.values
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
assert!(max - range > 1.0); }
#[test]
fn hw_multiplicative() {
let (ts, vals) = seasonal_data(200, 12);
let vals: Vec<f64> = vals.iter().map(|v| v + 100.0).collect();
let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(12), true);
model.fit(&ts, &vals).unwrap();
let result = model.predict(12).unwrap();
assert_eq!(result.values.len(), 12);
}
#[test]
fn hw_auto_period() {
let (ts, vals) = seasonal_data(200, 7);
let mut model = HoltWintersModel::new(None, None, None, None, false);
let result = model.fit(&ts, &vals);
if result.is_ok() {
let pred = model.predict(7).unwrap();
assert_eq!(pred.values.len(), 7);
}
}
#[test]
fn hw_insufficient_data_degrades_gracefully() {
let mut model = HoltWintersModel::new(None, None, None, Some(24), false);
let ts: Vec<i64> = (0..10).map(|i| i * 1_000_000_000).collect();
let vals: Vec<f64> = (0..10).map(|i| 50.0 + i as f64 * 2.0).collect();
model.fit(&ts, &vals).unwrap(); let result = model.predict(5).unwrap();
assert_eq!(result.values.len(), 5);
assert!(result.values[4] > result.values[0]);
}
#[test]
fn hw_non_seasonal_predicts_trend() {
let ts: Vec<i64> = (0..5).map(|i| i * 1_000_000_000).collect();
let vals = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let mut model = HoltWintersModel::new(Some(0.8), Some(0.2), None, Some(1), false);
model.fit(&ts, &vals).unwrap();
let result = model.predict(3).unwrap();
assert_eq!(result.values.len(), 3);
assert!(result.values[0] > 45.0);
}
#[test]
fn hw_update() {
let (ts, vals) = seasonal_data(100, 12);
let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(12), false);
model.fit(&ts, &vals).unwrap();
model.update(100_000_000_000, 200.0).unwrap();
let ModelParams::HoltWinters { level, .. } = model.params() else {
panic!()
};
assert!(*level > 50.0);
}
#[test]
fn hw_multiplicative_negative_seasonal_factors() {
let period = 4;
let n = 80;
let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
let vals: Vec<f64> = (0..n)
.map(|i| {
let base = 5.0 + i as f64 * 0.2;
let seasonal = match i % period {
0 => 1.2,
1 => 0.8,
2 => -0.3, 3 => 1.3,
_ => unreachable!(),
};
base * seasonal
})
.collect();
let mut model = HoltWintersModel::new(Some(0.3), Some(0.1), Some(0.2), Some(period), true);
model.fit(&ts, &vals).unwrap();
let result = model.predict(period).unwrap();
assert_eq!(result.values.len(), period);
assert!(
result.values[2] < 0.0,
"Expected negative forecast at seasonal index 2, got {}",
result.values[2]
);
assert!(result.values[0] > 0.0);
assert!(result.values[1] > 0.0);
assert!(result.values[3] > 0.0);
}
#[test]
fn safe_div_preserves_sign() {
assert!((safe_div(10.0, -0.5) - -20.0).abs() < 1e-6);
assert!((safe_div(10.0, 0.5) - 20.0).abs() < 1e-6);
assert!(safe_div(1.0, 1e-15) > 0.0);
assert!(safe_div(1.0, -1e-15) < 0.0);
}
}