1use crate::errors::{ChronosError, Result};
2use crate::linalg;
3use chrono::{Datelike, NaiveDate};
4use ndarray::{s, Array1, Array2};
5use rand::Rng;
6use rand_distr::{Distribution, Normal};
7use rayon::prelude::*;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SeasonalitySpec {
13 pub name: String,
14 pub period_days: f64,
15 pub fourier_order: usize,
16 pub prior_scale: f64, }
18
19impl Default for SeasonalitySpec {
20 fn default() -> Self {
21 Self {
22 name: String::new(),
23 period_days: 365.25,
24 fourier_order: 3,
25 prior_scale: 10.0, }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ProphetPrediction {
32 #[serde(with = "crate::utils::serde_array1")]
34 pub yhat: Array1<f64>,
35 #[serde(default, with = "crate::utils::serde_opt_array1")]
36 pub yhat_lower: Option<Array1<f64>>,
37 #[serde(default, with = "crate::utils::serde_opt_array1")]
38 pub yhat_upper: Option<Array1<f64>>,
39
40 #[serde(with = "crate::utils::serde_array1")]
42 pub trend: Array1<f64>,
43 #[serde(default, with = "crate::utils::serde_opt_array1")]
44 pub trend_lower: Option<Array1<f64>>,
45 #[serde(default, with = "crate::utils::serde_opt_array1")]
46 pub trend_upper: Option<Array1<f64>>,
47
48 #[serde(with = "crate::utils::serde_array1")]
50 pub seasonal: Array1<f64>,
51
52 pub seasonalities: HashMap<String, Array1<f64>>,
54 pub seasonalities_lower: HashMap<String, Array1<f64>>,
55 pub seasonalities_upper: HashMap<String, Array1<f64>>,
56
57 #[serde(with = "crate::utils::serde_array1")]
59 pub holidays: Array1<f64>,
60}
61
62impl ProphetPrediction {
63 pub fn len(&self) -> usize {
65 self.yhat.len()
66 }
67
68 pub fn is_empty(&self) -> bool {
70 self.yhat.is_empty()
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
75pub enum SeasonalityMode {
76 #[default]
77 Additive,
78 Multiplicative,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82pub enum TrendType {
83 #[default]
84 Linear,
85 Logistic,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Holiday {
90 pub name: String,
91 pub dates: Vec<NaiveDate>,
92 pub lower_window: i64,
93 pub upper_window: i64,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct ProphetDecomposition {
98 pub trend_type: TrendType,
99 pub seasonality_mode: SeasonalityMode, #[serde(default, with = "crate::utils::serde_opt_array1")]
103 pub cap: Option<Array1<f64>>,
104
105 #[serde(default, with = "crate::utils::serde_opt_array1")]
107 pub floor: Option<Array1<f64>>,
108
109 #[serde(default, with = "crate::utils::serde_opt_array1")]
110 pub capacities: Option<Array1<f64>>,
111 pub n_changepoints: usize,
112 pub changepoint_range: f64,
113 pub changepoint_prior_scale: f64,
114 pub holiday_prior_scale: f64,
115 pub seasonalities: Vec<SeasonalitySpec>,
116 pub holidays: Vec<Holiday>,
117
118 pub t0_days: Option<f64>,
120 pub total_days: Option<f64>,
121
122 pub changepoints: Option<Vec<f64>>,
124 #[serde(default, with = "crate::utils::serde_opt_array1")]
125 pub delta: Option<Array1<f64>>,
126 pub k: Option<f64>,
127 pub m: Option<f64>,
128 #[serde(default, with = "crate::utils::serde_opt_array1")]
129 pub beta: Option<Array1<f64>>,
130}
131
132impl ProphetDecomposition {
133 pub fn new(n_changepoints: usize, changepoint_prior_scale: f64) -> Self {
134 Self {
135 trend_type: TrendType::Linear,
136 capacities: None,
137 cap: None,
138 floor: None,
139 n_changepoints,
140 changepoint_range: 0.8,
141 changepoint_prior_scale,
142 holiday_prior_scale: 10.0,
143 seasonality_mode: SeasonalityMode::Additive,
144 seasonalities: Vec::new(),
145 holidays: Vec::new(),
146 t0_days: None,
147 total_days: None,
148 changepoints: None,
149 delta: None,
150 k: None,
151 m: None,
152 beta: None,
153 }
154 }
155
156 pub fn to_json(&self) -> Result<String> {
158 serde_json::to_string_pretty(self).map_err(|e| {
159 ChronosError::InvalidParameters(format!("Failed to serialize model: {}", e))
160 })
161 }
162
163 pub fn from_json(json_str: &str) -> Result<Self> {
165 serde_json::from_str(json_str).map_err(|e| {
166 ChronosError::InvalidParameters(format!("Failed to deserialize model: {}", e))
167 })
168 }
169
170 pub fn save_to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
172 let json_data = self.to_json()?;
173 std::fs::write(path, json_data).map_err(|e| {
174 ChronosError::InvalidParameters(format!("Failed to write model file: {}", e))
175 })
176 }
177
178 pub fn load_from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
180 let json_data = std::fs::read_to_string(path).map_err(|e| {
181 ChronosError::InvalidParameters(format!("Failed to read model file: {}", e))
182 })?;
183 Self::from_json(&json_data)
184 }
185
186 #[allow(clippy::too_many_arguments)]
188 fn evaluate_logistic_trend(
189 &self,
190 t_norm: &Array1<f64>,
191 cps: &[f64],
192 k: f64,
193 m: f64,
194 delta: &Array1<f64>,
195 cap: &Array1<f64>,
196 floor: Option<&Array1<f64>>,
197 ) -> Array1<f64> {
198 let n = t_norm.len();
199 let mut trend = Array1::<f64>::zeros(n);
200 let default_floor = Array1::<f64>::zeros(n);
201 let f = floor.unwrap_or(&default_floor);
202
203 for i in 0..n {
204 let t = t_norm[i];
205 let mut rate = k;
206 let mut gamma = 0.0;
207
208 for (j, &s_j) in cps.iter().enumerate() {
210 if t >= s_j {
211 let d = delta[j];
212 rate += d;
213 gamma += (s_j - m - gamma / rate) * (1.0 - (rate - d) / rate);
215 }
216 }
217
218 let c_i = cap[i];
219 let f_i = f[i];
220 let net_cap = (c_i - f_i).max(1e-5);
221
222 let exp_term = (-rate * (t - (m + gamma))).exp();
224 trend[i] = f_i + net_cap / (1.0 + exp_term);
225 }
226
227 trend
228 }
229
230 pub fn add_seasonality(&mut self, name: &str, period_days: f64, fourier_order: usize) {
232 self.add_seasonality_with_prior(name, period_days, fourier_order, 10.0);
233 }
234
235 pub fn add_seasonality_with_prior(
237 &mut self,
238 name: &str,
239 period_days: f64,
240 fourier_order: usize,
241 prior_scale: f64,
242 ) {
243 self.seasonalities.push(SeasonalitySpec {
244 name: name.to_string(),
245 period_days,
246 fourier_order,
247 prior_scale,
248 });
249 }
250
251 pub fn add_holiday(&mut self, holiday: Holiday) {
252 self.holidays.push(holiday);
253 }
254
255 fn normalize_time(&self, dates: &[NaiveDate]) -> Result<Array1<f64>> {
257 let t0 = self.t0_days.ok_or_else(|| {
258 ChronosError::InvalidParameters(
259 "Model must be fit before normalizing prediction dates".into(),
260 )
261 })?;
262 let total = self.total_days.unwrap_or(1.0);
263
264 Ok(Array1::from_vec(
265 dates
266 .iter()
267 .map(|d| (d.num_days_from_ce() as f64 - t0) / total)
268 .collect(),
269 ))
270 }
271
272 fn build_changepoint_matrix(&self, t_norm: &Array1<f64>, changepoints: &[f64]) -> Array2<f64> {
273 let n = t_norm.len();
274 let s_len = changepoints.len();
275 let mut a = Array2::<f64>::zeros((n, s_len));
276
277 for i in 0..n {
278 for j in 0..s_len {
279 if t_norm[i] >= changepoints[j] {
280 a[[i, j]] = 1.0;
281 }
282 }
283 }
284 a
285 }
286
287 fn build_seasonal_and_holiday_matrix(&self, dates: &[NaiveDate]) -> Array2<f64> {
288 let n = dates.len();
289 let total_fourier_cols: usize =
290 self.seasonalities.iter().map(|s| s.fourier_order * 2).sum();
291 let num_holidays = self.holidays.len();
292 let cols = total_fourier_cols + num_holidays;
293
294 if cols == 0 {
295 return Array2::zeros((n, 0));
296 }
297
298 let mut x = Array2::<f64>::zeros((n, cols));
299 let t0 = self
300 .t0_days
301 .unwrap_or_else(|| dates[0].num_days_from_ce() as f64);
302
303 let mut col_offset = 0;
304
305 for spec in &self.seasonalities {
306 for i in 0..n {
307 let t_days = dates[i].num_days_from_ce() as f64 - t0;
308 for j in 0..spec.fourier_order {
309 let n_term = (j + 1) as f64;
310 let arg = 2.0 * std::f64::consts::PI * n_term * t_days / spec.period_days;
311 x[[i, col_offset + 2 * j]] = arg.sin();
312 x[[i, col_offset + 2 * j + 1]] = arg.cos();
313 }
314 }
315 col_offset += spec.fourier_order * 2;
316 }
317
318 for (h_idx, holiday) in self.holidays.iter().enumerate() {
319 for i in 0..n {
320 let mut active = 0.0;
321 for h_date in &holiday.dates {
322 let diff = (dates[i] - *h_date).num_days();
323 if diff >= holiday.lower_window && diff <= holiday.upper_window {
324 active = 1.0;
325 break;
326 }
327 }
328 x[[i, col_offset + h_idx]] = active;
329 }
330 }
331
332 x
333 }
334
335 pub fn predict_with_intervals(
337 &self,
338 dates: &[NaiveDate],
339 interval_width: f64,
340 n_samples: usize,
341 ) -> Result<ProphetPrediction> {
342 let n_obs = dates.len();
343 let base_pred = self.predict(dates)?;
344
345 if n_samples == 0 {
346 return Ok(base_pred);
347 }
348
349 let delta = self.delta.as_ref().ok_or_else(|| {
351 ChronosError::InvalidParameters(
352 "Model must be fitted before predicting intervals".into(),
353 )
354 })?;
355
356 let abs_mean_delta = delta.mapv(|d| d.abs()).mean().unwrap_or(0.01);
357 let n_historical = delta.len();
358 let changepoint_prob = (self.n_changepoints as f64) / (n_historical as f64).max(1.0);
359 let b = abs_mean_delta.max(1e-5);
360 let seasonality_mode = self.seasonality_mode;
361
362 let samples: Vec<(Vec<f64>, Vec<f64>)> = (0..n_samples)
364 .into_par_iter()
365 .map(|_| {
366 let mut rng = rand::thread_rng();
367 let noise_dist = Normal::new(0.0, 0.01).unwrap();
368
369 let mut trend_draws = vec![0.0; n_obs];
370 let mut yhat_draws = vec![0.0; n_obs];
371 let mut sampled_slope_change = 0.0;
372
373 for t in 0..n_obs {
374 if rng.gen_bool(changepoint_prob.min(1.0)) {
375 let u: f64 = rng.gen_range(-0.5..0.5);
377 let laplace_sample = -b * u.signum() * (1.0 - 2.0 * u.abs()).ln();
378 sampled_slope_change += laplace_sample;
379 }
380
381 let trend_draw = base_pred.trend[t] + sampled_slope_change * (t as f64);
382 trend_draws[t] = trend_draw;
383
384 let noise = noise_dist.sample(&mut rng);
385 let yhat_draw = match seasonality_mode {
386 SeasonalityMode::Additive => {
387 trend_draw + base_pred.seasonal[t] + base_pred.holidays[t] + noise
388 }
389 SeasonalityMode::Multiplicative => {
390 trend_draw * (1.0 + base_pred.seasonal[t] + base_pred.holidays[t])
391 + noise
392 }
393 };
394
395 yhat_draws[t] = yhat_draw;
396 }
397
398 (trend_draws, yhat_draws)
399 })
400 .collect();
401
402 let alpha = (1.0 - interval_width) / 2.0;
404 let lower_idx = ((alpha * n_samples as f64).floor() as usize).min(n_samples - 1);
405 let upper_idx = (((1.0 - alpha) * n_samples as f64).ceil() as usize).min(n_samples - 1);
406
407 let mut trend_lower = vec![0.0; n_obs];
408 let mut trend_upper = vec![0.0; n_obs];
409 let mut yhat_lower = vec![0.0; n_obs];
410 let mut yhat_upper = vec![0.0; n_obs];
411
412 let bounds: Vec<(f64, f64, f64, f64)> = (0..n_obs)
414 .into_par_iter()
415 .map(|t| {
416 let mut t_col: Vec<f64> = samples.iter().map(|s| s.0[t]).collect();
417 t_col.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
418
419 let mut y_col: Vec<f64> = samples.iter().map(|s| s.1[t]).collect();
420 y_col.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
421
422 (
423 t_col[lower_idx],
424 t_col[upper_idx],
425 y_col[lower_idx],
426 y_col[upper_idx],
427 )
428 })
429 .collect();
430
431 for (t, (tl, tu, yl, yu)) in bounds.into_iter().enumerate() {
432 trend_lower[t] = tl;
433 trend_upper[t] = tu;
434 yhat_lower[t] = yl;
435 yhat_upper[t] = yu;
436 }
437
438 let mut res = base_pred;
440 res.trend_lower = Some(Array1::from_vec(trend_lower));
441 res.trend_upper = Some(Array1::from_vec(trend_upper));
442 res.yhat_lower = Some(Array1::from_vec(yhat_lower));
443 res.yhat_upper = Some(Array1::from_vec(yhat_upper));
444
445 Ok(res)
446 }
447
448 pub fn fit(
449 &mut self,
450 dates: &[NaiveDate],
451 y: &Array1<f64>,
452 cap: Option<&Array1<f64>>,
453 floor: Option<&Array1<f64>>,
454 ) -> Result<()> {
455 let n = dates.len();
456 if n < 2 {
457 return Err(ChronosError::InsufficientData {
458 required: 2,
459 found: n,
460 });
461 }
462
463 self.cap = cap.cloned();
464 self.floor = floor.cloned();
465
466 let t0 = dates[0].num_days_from_ce() as f64;
468 let t_end = dates.last().unwrap().num_days_from_ce() as f64;
469 let total = (t_end - t0).max(1.0);
470
471 self.t0_days = Some(t0);
472 self.total_days = Some(total);
473
474 let t_norm = self.normalize_time(dates)?;
475
476 let y_target = match self.trend_type {
478 TrendType::Linear => y.clone(),
479 TrendType::Logistic => {
480 let cap_arr = self.cap.as_ref().ok_or_else(|| {
481 ChronosError::InvalidParameters("Logistic growth requires cap values".into())
482 })?;
483 let default_floor = Array1::<f64>::zeros(n);
484 let f = self.floor.as_ref().unwrap_or(&default_floor);
485
486 let mut y_transformed = Array1::<f64>::zeros(n);
487 for i in 0..n {
488 let net_cap = (cap_arr[i] - f[i]).max(1e-5);
489 let p = ((y[i] - f[i]) / net_cap).clamp(1e-4, 1.0 - 1e-4);
490 y_transformed[i] = (p / (1.0 - p)).ln();
491 }
492 y_transformed
493 }
494 };
495
496 let max_cp_t = self.changepoint_range;
497 let mut cps = Vec::with_capacity(self.n_changepoints);
498 for i in 1..=self.n_changepoints {
499 cps.push((i as f64 / (self.n_changepoints + 1) as f64) * max_cp_t);
500 }
501
502 let a_cp = self.build_changepoint_matrix(&t_norm, &cps);
503 let x_seasonal = self.build_seasonal_and_holiday_matrix(dates);
504
505 let n_seasonal_cols = x_seasonal.ncols();
506 let total_cols = 2 + self.n_changepoints + n_seasonal_cols;
507
508 let mut x = Array2::<f64>::zeros((n, total_cols));
509
510 let is_multiplicative = self.seasonality_mode == SeasonalityMode::Multiplicative;
511
512 let y_start = y[0];
514 let y_end = y[n - 1];
515
516 for i in 0..n {
517 x[[i, 0]] = 1.0;
518 x[[i, 1]] = t_norm[i];
519
520 for j in 0..self.n_changepoints {
521 if a_cp[[i, j]] > 0.0 {
522 x[[i, 2 + j]] = (t_norm[i] - cps[j]) * a_cp[[i, j]];
523 }
524 }
525
526 let trend_scale = if is_multiplicative {
528 (y_start + (y_end - y_start) * t_norm[i]).max(1e-3)
529 } else {
530 1.0
531 };
532
533 for j in 0..n_seasonal_cols {
535 x[[i, 2 + self.n_changepoints + j]] = x_seasonal[[i, j]] * trend_scale;
536 }
537 }
538
539 let mut xtx = x.t().dot(&x);
540
541 let lambda_cp = 1.0 / (self.changepoint_prior_scale.powi(2)).max(1e-5);
543 for j in 0..self.n_changepoints {
544 xtx[[2 + j, 2 + j]] += lambda_cp;
545 }
546
547 let mut col_offset = 2 + self.n_changepoints;
549
550 for spec in &self.seasonalities {
551 let fourier_cols = spec.fourier_order * 2;
552 let lambda_spec = 1.0 / (spec.prior_scale.powi(2)).max(1e-5);
553
554 for col in col_offset..(col_offset + fourier_cols) {
555 xtx[[col, col]] += lambda_spec;
556 }
557
558 col_offset += fourier_cols;
559 }
560
561 let lambda_holiday = 1.0 / (self.holiday_prior_scale.powi(2)).max(1e-5);
563 for h_idx in 0..self.holidays.len() {
564 let col = col_offset + h_idx;
565 xtx[[col, col]] += lambda_holiday;
566 }
567
568 let xty = x.t().dot(&y_target);
570 let coeffs = linalg::solve(&xtx, &xty).map_err(ChronosError::LinalgError)?;
571
572 self.m = Some(coeffs[0]);
573 self.k = Some(coeffs[1]);
574 self.delta = Some(coeffs.slice(s![2..2 + self.n_changepoints]).to_owned());
575
576 if n_seasonal_cols > 0 {
577 self.beta = Some(coeffs.slice(s![2 + self.n_changepoints..]).to_owned());
578 }
579
580 self.changepoints = Some(cps);
581
582 Ok(())
583 }
584
585 pub fn predict(&self, dates: &[NaiveDate]) -> Result<ProphetPrediction> {
586 let cps = self.changepoints.as_ref().ok_or_else(|| {
587 ChronosError::InvalidParameters("Model must be fit before calling predict".into())
588 })?;
589 let delta = self.delta.as_ref().unwrap();
590 let k = self.k.unwrap();
591 let m = self.m.unwrap();
592
593 let n = dates.len();
594 let t_norm = self.normalize_time(dates)?;
595 let a_cp = self.build_changepoint_matrix(&t_norm, cps);
596
597 let trend = match self.trend_type {
599 TrendType::Linear => {
600 let mut tr = Array1::<f64>::zeros(n);
601 for i in 0..n {
602 let mut rate = k;
603 let mut offset = m;
604 for j in 0..self.n_changepoints {
605 if a_cp[[i, j]] > 0.0 {
606 rate += delta[j];
607 offset -= cps[j] * delta[j];
608 }
609 }
610 tr[i] = rate * t_norm[i] + offset;
611 }
612 tr
613 }
614 TrendType::Logistic => {
615 let cap = self.cap.as_ref().ok_or_else(|| {
616 ChronosError::InvalidParameters("Logistic growth requires cap values".into())
617 })?;
618 self.evaluate_logistic_trend(&t_norm, cps, k, m, delta, cap, self.floor.as_ref())
619 }
620 };
621
622 let mut seasonal_total = Array1::<f64>::zeros(n);
624 let mut holiday_total = Array1::<f64>::zeros(n);
625 let mut seasonalities_map = HashMap::new();
626
627 if let Some(ref beta) = self.beta {
628 let t0 = self
629 .t0_days
630 .unwrap_or_else(|| dates[0].num_days_from_ce() as f64);
631 let mut col_offset = 0;
632
633 for spec in &self.seasonalities {
635 let mut spec_component = Array1::<f64>::zeros(n);
636 let fourier_cols = spec.fourier_order * 2;
637
638 for i in 0..n {
639 let t_days = dates[i].num_days_from_ce() as f64 - t0;
640 let mut val = 0.0;
641 for j in 0..spec.fourier_order {
642 let n_term = (j + 1) as f64;
643 let arg = 2.0 * std::f64::consts::PI * n_term * t_days / spec.period_days;
644
645 let sin_coef = beta[col_offset + 2 * j];
646 let cos_coef = beta[col_offset + 2 * j + 1];
647
648 val += sin_coef * arg.sin() + cos_coef * arg.cos();
649 }
650 spec_component[i] = val;
651 }
652
653 seasonal_total = &seasonal_total + &spec_component;
654 seasonalities_map.insert(spec.name.clone(), spec_component);
655 col_offset += fourier_cols;
656 }
657
658 for (h_idx, holiday) in self.holidays.iter().enumerate() {
660 let coef = beta[col_offset + h_idx];
661 for i in 0..n {
662 for h_date in &holiday.dates {
663 let diff = (dates[i] - *h_date).num_days();
664 if diff >= holiday.lower_window && diff <= holiday.upper_window {
665 holiday_total[i] += coef;
666 break;
667 }
668 }
669 }
670 }
671 }
672
673 let yhat = match self.seasonality_mode {
675 SeasonalityMode::Additive => &trend + &seasonal_total + &holiday_total,
676 SeasonalityMode::Multiplicative => {
677 &trend * (1.0 + &seasonal_total + &holiday_total)
679 }
680 };
681
682 Ok(ProphetPrediction {
683 yhat,
684 yhat_lower: None,
685 yhat_upper: None,
686 trend,
687 trend_lower: None,
688 trend_upper: None,
689 seasonal: seasonal_total,
690 seasonalities: seasonalities_map,
691 seasonalities_lower: HashMap::new(),
692 seasonalities_upper: HashMap::new(),
693 holidays: holiday_total,
694 })
695 }
696}