use super::dist::GaussianMixture;
use super::forecaster::LaplaceForecaster;
use super::DistributionalForecaster;
use crate::core::{Forecast, TimeSeries};
use crate::error::{ForecastError, Result};
use crate::models::traits::Forecaster;
use chrono::{Duration, TimeZone, Utc};
pub struct MultiScaleLaplace {
scales: Vec<(usize, LaplaceForecaster)>,
max_horizon: usize,
period_hint: Option<usize>,
scale_scores: Vec<f64>,
scoring_window: Option<usize>,
enable_scoring_horizon: bool,
}
fn default_scales(
horizon: usize,
n_train: usize,
min_samples: usize,
period: Option<usize>,
) -> Vec<usize> {
let mut out = vec![1usize];
let candidates: Vec<usize> = if let Some(p) = period {
vec![p, horizon]
} else {
let sqrt_k = (horizon as f64).sqrt().ceil() as usize;
vec![sqrt_k, horizon]
};
for s in candidates {
if s > 1 && s <= horizon && n_train / s >= min_samples && !out.contains(&s) {
out.push(s);
}
}
out.sort();
out.dedup();
out
}
impl MultiScaleLaplace {
pub fn skaters(max_horizon: usize) -> Self {
Self {
scales: Vec::new(),
max_horizon,
period_hint: None,
scale_scores: Vec::new(),
scoring_window: None,
enable_scoring_horizon: false,
}
}
pub fn with_scoring_window(mut self, w: usize) -> Self {
self.scoring_window = Some(w);
self
}
pub fn with_scoring_horizon(mut self) -> Self {
self.enable_scoring_horizon = true;
self
}
pub fn with_period(mut self, period: usize) -> Self {
self.period_hint = Some(period);
self
}
pub fn strides(&self) -> Vec<usize> {
self.scales.iter().map(|(s, _)| *s).collect()
}
fn decimate(values: &[f64], stride: usize) -> Vec<f64> {
if stride <= 1 {
values.to_vec()
} else {
values.iter().step_by(stride).copied().collect()
}
}
fn decimated_ts(source: &TimeSeries, stride: usize) -> Result<TimeSeries> {
let values = Self::decimate(source.primary_values(), stride);
let base = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
let stamps: Vec<_> = (0..values.len())
.map(|i| base + Duration::hours((i * stride.max(1)) as i64))
.collect();
TimeSeries::univariate(stamps, values)
}
}
impl Forecaster for MultiScaleLaplace {
fn fit(&mut self, series: &TimeSeries) -> Result<()> {
let n = series.primary_values().len();
let strides = default_scales(self.max_horizon, n, 50, self.period_hint);
self.scales.clear();
self.scale_scores.clear();
self.scales.reserve(strides.len());
self.scale_scores.reserve(strides.len());
for s in strides {
let ts = Self::decimated_ts(series, s)?;
let mut f = LaplaceForecaster::new().skaters();
if s == 1 {
if let Some(p) = self.period_hint {
if p >= 2 {
f = f.auto_with_seasonal_period(p);
}
}
}
if self.enable_scoring_horizon {
let coarse_h = self.max_horizon.div_ceil(s).max(1);
f = f.with_scoring_horizon(coarse_h);
}
if let Some(w) = self.scoring_window {
f = f.with_scoring_window(w);
}
f.fit(&ts)?;
let residuals = f.residuals().unwrap_or(&[]);
let sigma = {
let n_r = residuals.len().max(1) as f64;
let mean: f64 = residuals.iter().sum::<f64>() / n_r;
let var: f64 = residuals.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n_r;
var.sqrt().max(1e-9)
};
let ll = if residuals.is_empty() {
f64::NEG_INFINITY
} else {
let n_r = residuals.len() as f64;
let mut acc = 0.0;
let two_pi_var = 2.0 * std::f64::consts::PI * sigma * sigma;
let log_c = -0.5 * two_pi_var.ln();
for &r in residuals {
acc += log_c - 0.5 * (r / sigma).powi(2);
}
acc / n_r
};
self.scales.push((s, f));
self.scale_scores.push(ll);
}
Ok(())
}
fn predict(&self, horizon: usize) -> Result<Forecast> {
if self.scales.is_empty() {
return Err(ForecastError::FitRequired {
model: Some("MultiScaleLaplace".into()),
});
}
let mut means = Vec::with_capacity(horizon);
for h in 1..=horizon {
let (s, f) = self
.scales
.iter()
.filter(|(s, _)| *s <= h)
.max_by_key(|(s, _)| *s)
.unwrap_or(&self.scales[0]);
let steps = h.div_ceil(*s);
let fc = f.predict(steps)?;
let p = fc.primary();
if p.len() >= steps {
means.push(p[steps - 1]);
} else {
means.push(0.0);
}
}
Ok(Forecast::from_values(means))
}
fn name(&self) -> &str {
"MultiScaleLaplace"
}
fn fitted_values(&self) -> Option<&[f64]> {
self.scales
.iter()
.find(|(s, _)| *s == 1)
.and_then(|(_, f)| f.fitted_values())
}
fn residuals(&self) -> Option<&[f64]> {
self.scales
.iter()
.find(|(s, _)| *s == 1)
.and_then(|(_, f)| f.residuals())
}
}
impl DistributionalForecaster for MultiScaleLaplace {
fn forecast_dist(&self, horizon: usize) -> Result<Vec<GaussianMixture>> {
if self.scales.is_empty() {
return Err(ForecastError::FitRequired {
model: Some("MultiScaleLaplace".into()),
});
}
let per_scale_dists: Vec<Vec<GaussianMixture>> = self
.scales
.iter()
.map(|(s, f)| {
let coarse = horizon.div_ceil(*s);
f.forecast_dist(coarse).unwrap_or_default()
})
.collect();
let max_score = self
.scale_scores
.iter()
.cloned()
.filter(|v| v.is_finite())
.fold(f64::NEG_INFINITY, f64::max);
let mut out = Vec::with_capacity(horizon);
for h in 1..=horizon {
let mut comps: Vec<(f64, super::dist::Gaussian)> = Vec::new();
for (i, (s, _)) in self.scales.iter().enumerate() {
if *s > h {
continue;
}
let coarse_idx = h.div_ceil(*s).saturating_sub(1);
if coarse_idx >= per_scale_dists[i].len() {
continue;
}
let scale_w = if max_score.is_finite() && self.scale_scores[i].is_finite() {
(self.scale_scores[i] - max_score).exp()
} else {
1.0
};
let mixture = &per_scale_dists[i][coarse_idx];
for (w, g) in &mixture.components {
comps.push((scale_w * w, *g));
}
}
out.push(GaussianMixture::new(comps).prune(20));
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::TimeSeries;
use chrono::{Duration, TimeZone, Utc};
fn periodic_ts(n: usize, period: usize) -> TimeSeries {
let base = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let vals: Vec<f64> = (0..n)
.map(|i| {
let phase = (i % period) as f64 / period as f64;
100.0
+ 30.0 * (2.0 * std::f64::consts::PI * phase).sin()
+ ((i as f64 * 12.9898).sin() * 43758.5453).fract()
})
.collect();
let stamps: Vec<_> = (0..n).map(|i| base + Duration::hours(i as i64)).collect();
TimeSeries::univariate(stamps, vals).unwrap()
}
#[test]
fn strides_include_1_and_sqrt_k_and_k() {
let strides = default_scales(48, 1000, 10, None);
assert_eq!(strides, vec![1, 7, 48]);
}
#[test]
fn strides_dropped_when_too_few_samples() {
let strides = default_scales(48, 20, 5, None);
assert_eq!(strides, vec![1]);
}
#[test]
fn strides_include_period_when_hint_given() {
let strides = default_scales(48, 1000, 10, Some(24));
assert_eq!(strides, vec![1, 24, 48]);
}
#[test]
fn fit_predict_produces_horizon_values() {
let ts = periodic_ts(700, 24);
let mut m = MultiScaleLaplace::skaters(48);
m.fit(&ts).unwrap();
let fc = m.predict(48).unwrap();
assert_eq!(fc.primary().len(), 48);
for v in fc.primary() {
assert!(v.is_finite(), "non-finite forecast value: {v}");
}
}
#[test]
fn predict_before_fit_errors() {
let m = MultiScaleLaplace::skaters(10);
assert!(m.predict(5).is_err());
}
}