use crate::core::{Forecast, TimeSeries};
use crate::error::Result;
use crate::models::Forecaster;
use rand::prelude::*;
use rand::SeedableRng;
#[derive(Debug, Clone)]
pub struct BootstrapConfig {
pub n_samples: usize,
pub block_size: Option<usize>,
pub seed: Option<u64>,
}
impl Default for BootstrapConfig {
fn default() -> Self {
Self {
n_samples: 1000,
block_size: None,
seed: None,
}
}
}
impl BootstrapConfig {
pub fn new(n_samples: usize) -> Self {
Self {
n_samples,
..Default::default()
}
}
pub fn with_block_size(mut self, block_size: usize) -> Self {
self.block_size = Some(block_size);
self
}
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
}
#[derive(Debug, Clone)]
pub struct BootstrapResult {
pub lower: Vec<f64>,
pub upper: Vec<f64>,
pub level: f64,
pub n_samples: usize,
}
fn resample_residuals(residuals: &[f64], rng: &mut impl Rng) -> Vec<f64> {
let n = residuals.len();
(0..n).map(|_| residuals[rng.gen_range(0..n)]).collect()
}
fn resample_blocks(residuals: &[f64], block_size: usize, rng: &mut impl Rng) -> Vec<f64> {
let n = residuals.len();
if block_size == 0 || block_size > n {
return resample_residuals(residuals, rng);
}
let mut result = Vec::with_capacity(n);
let n_blocks = n / block_size + 1;
for _ in 0..n_blocks {
let start = rng.gen_range(0..=(n - block_size));
for j in 0..block_size {
if result.len() >= n {
break;
}
result.push(residuals[start + j]);
}
}
result.truncate(n);
result
}
pub fn bootstrap_intervals<M: Forecaster + Clone>(
model: &M,
series: &TimeSeries,
horizon: usize,
level: f64,
config: &BootstrapConfig,
) -> Result<BootstrapResult> {
let residuals = model
.residuals()
.ok_or(crate::error::ForecastError::FitRequired)?;
let fitted = model
.fitted_values()
.ok_or(crate::error::ForecastError::FitRequired)?;
let valid_residuals: Vec<f64> = residuals.iter().copied().filter(|r| !r.is_nan()).collect();
if valid_residuals.is_empty() {
return Err(crate::error::ForecastError::ComputationError(
"No valid residuals for bootstrap".to_string(),
));
}
let mut rng: StdRng = match config.seed {
Some(seed) => StdRng::seed_from_u64(seed),
None => StdRng::from_entropy(),
};
let mut forecast_samples: Vec<Vec<f64>> = vec![Vec::with_capacity(config.n_samples); horizon];
for _ in 0..config.n_samples {
let resampled = match config.block_size {
Some(bs) => resample_blocks(&valid_residuals, bs, &mut rng),
None => resample_residuals(&valid_residuals, &mut rng),
};
let synthetic_values: Vec<f64> = fitted
.iter()
.zip(resampled.iter().cycle())
.map(|(f, r)| f + r)
.collect();
let synthetic_ts =
TimeSeries::univariate(series.timestamps().to_vec(), synthetic_values.clone());
if let Ok(ts) = synthetic_ts {
let mut bootstrap_model = model.clone();
if bootstrap_model.fit(&ts).is_ok() {
if let Ok(forecast) = bootstrap_model.predict(horizon) {
for (h, &val) in forecast.primary().iter().enumerate() {
if val.is_finite() {
forecast_samples[h].push(val);
}
}
}
}
}
}
let alpha = (1.0 - level) / 2.0;
let mut lower = Vec::with_capacity(horizon);
let mut upper = Vec::with_capacity(horizon);
for samples in &mut forecast_samples {
if samples.is_empty() {
lower.push(f64::NAN);
upper.push(f64::NAN);
continue;
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = samples.len();
let lower_idx = ((alpha * n as f64).floor() as usize).min(n - 1);
let upper_idx = (((1.0 - alpha) * n as f64).floor() as usize).min(n - 1);
lower.push(samples[lower_idx]);
upper.push(samples[upper_idx]);
}
Ok(BootstrapResult {
lower,
upper,
level,
n_samples: config.n_samples,
})
}
pub fn bootstrap_forecast<M: Forecaster + Clone>(
model: &M,
series: &TimeSeries,
horizon: usize,
level: f64,
config: &BootstrapConfig,
) -> Result<Forecast> {
let point_forecast = model.predict(horizon)?;
let bootstrap_result = bootstrap_intervals(model, series, horizon, level, config)?;
Ok(Forecast::from_values_with_intervals(
point_forecast.primary().to_vec(),
bootstrap_result.lower,
bootstrap_result.upper,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::baseline::Naive;
use crate::models::exponential::SimpleExponentialSmoothing;
use chrono::{Duration, TimeZone, Utc};
fn make_timestamps(n: usize) -> Vec<chrono::DateTime<Utc>> {
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
(0..n).map(|i| base + Duration::hours(i as i64)).collect()
}
#[test]
fn bootstrap_config_default() {
let config = BootstrapConfig::default();
assert_eq!(config.n_samples, 1000);
assert!(config.block_size.is_none());
assert!(config.seed.is_none());
}
#[test]
fn bootstrap_config_builder() {
let config = BootstrapConfig::new(500).with_block_size(10).with_seed(42);
assert_eq!(config.n_samples, 500);
assert_eq!(config.block_size, Some(10));
assert_eq!(config.seed, Some(42));
}
#[test]
fn resample_residuals_length() {
let residuals = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let mut rng = StdRng::seed_from_u64(42);
let resampled = resample_residuals(&residuals, &mut rng);
assert_eq!(resampled.len(), residuals.len());
}
#[test]
fn resample_blocks_length() {
let residuals = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let mut rng = StdRng::seed_from_u64(42);
let resampled = resample_blocks(&residuals, 3, &mut rng);
assert_eq!(resampled.len(), residuals.len());
}
#[test]
fn bootstrap_intervals_naive() {
let timestamps = make_timestamps(50);
let values: Vec<f64> = (0..50).map(|i| 10.0 + (i as f64 * 0.3).sin()).collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = Naive::new();
model.fit(&ts).unwrap();
let config = BootstrapConfig::new(100).with_seed(42);
let result = bootstrap_intervals(&model, &ts, 5, 0.95, &config).unwrap();
assert_eq!(result.lower.len(), 5);
assert_eq!(result.upper.len(), 5);
assert_eq!(result.level, 0.95);
for i in 0..5 {
assert!(
result.lower[i] <= result.upper[i],
"Lower {} > Upper {} at horizon {}",
result.lower[i],
result.upper[i],
i
);
}
}
#[test]
fn bootstrap_intervals_ses() {
let timestamps = make_timestamps(50);
let values: Vec<f64> = (0..50).map(|i| 10.0 + (i as f64 * 0.3).sin()).collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SimpleExponentialSmoothing::auto();
model.fit(&ts).unwrap();
let config = BootstrapConfig::new(100).with_seed(123);
let result = bootstrap_intervals(&model, &ts, 5, 0.90, &config).unwrap();
assert_eq!(result.lower.len(), 5);
assert_eq!(result.upper.len(), 5);
assert_eq!(result.level, 0.90);
}
#[test]
fn bootstrap_forecast_contains_intervals() {
let timestamps = make_timestamps(50);
let values: Vec<f64> = (0..50).map(|i| 10.0 + i as f64 * 0.5).collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = Naive::new();
model.fit(&ts).unwrap();
let config = BootstrapConfig::new(50).with_seed(42);
let forecast = bootstrap_forecast(&model, &ts, 5, 0.95, &config).unwrap();
assert!(forecast.has_lower());
assert!(forecast.has_upper());
assert_eq!(forecast.horizon(), 5);
}
#[test]
fn bootstrap_reproducible_with_seed() {
let timestamps = make_timestamps(50);
let values: Vec<f64> = (0..50).map(|i| 10.0 + i as f64 * 0.5).collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = Naive::new();
model.fit(&ts).unwrap();
let config = BootstrapConfig::new(50).with_seed(42);
let result1 = bootstrap_intervals(&model, &ts, 5, 0.95, &config).unwrap();
let result2 = bootstrap_intervals(&model, &ts, 5, 0.95, &config).unwrap();
for i in 0..5 {
assert!(
(result1.lower[i] - result2.lower[i]).abs() < 1e-10,
"Results should be reproducible with seed"
);
}
}
#[test]
fn bootstrap_block_vs_residual() {
let timestamps = make_timestamps(100);
let values: Vec<f64> = (0..100)
.map(|i| 10.0 + (i as f64 * 0.1).sin() * 5.0)
.collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = Naive::new();
model.fit(&ts).unwrap();
let residual_config = BootstrapConfig::new(50).with_seed(42);
let block_config = BootstrapConfig::new(50).with_block_size(5).with_seed(42);
let residual_result = bootstrap_intervals(&model, &ts, 5, 0.95, &residual_config).unwrap();
let block_result = bootstrap_intervals(&model, &ts, 5, 0.95, &block_config).unwrap();
assert_eq!(residual_result.lower.len(), 5);
assert_eq!(block_result.lower.len(), 5);
for i in 0..5 {
assert!(residual_result.lower[i] <= residual_result.upper[i]);
assert!(block_result.lower[i] <= block_result.upper[i]);
}
}
}