Skip to main content

chronos_ts/
decomposition.rs

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, // Added field
17}
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, // Matching Prophet default seasonality prior scale
26        }
27    }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ProphetPrediction {
32    /// Combined forecast: trend + seasonality + holidays
33    #[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    /// Isolated g(t) trend signal
41    #[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    /// Aggregated seasonal effects (sum of all Fourier orders)
49    #[serde(with = "crate::utils::serde_array1")]
50    pub seasonal: Array1<f64>,
51
52    /// Breakdown of individual seasonality terms by name (e.g., "yearly", "weekly")
53    pub seasonalities: HashMap<String, Array1<f64>>,
54    pub seasonalities_lower: HashMap<String, Array1<f64>>,
55    pub seasonalities_upper: HashMap<String, Array1<f64>>,
56
57    /// Aggregated holiday adjustments
58    #[serde(with = "crate::utils::serde_array1")]
59    pub holidays: Array1<f64>,
60}
61
62impl ProphetPrediction {
63    /// Returns the number of prediction points
64    pub fn len(&self) -> usize {
65        self.yhat.len()
66    }
67
68    /// Returns true if the prediction vector is empty
69    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, // Additive or Multiplicative
100
101    /// Upper capacity bound per observation date C(t)
102    #[serde(default, with = "crate::utils::serde_opt_array1")]
103    pub cap: Option<Array1<f64>>,
104
105    /// Optional lower capacity floor per observation date F(t) (default: 0.0)
106    #[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    // Training time normalization state
119    pub t0_days: Option<f64>,
120    pub total_days: Option<f64>,
121
122    // Fitted Parameters
123    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    /// Serializes the model state (including fitted coefficients) to a JSON string.
157    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    /// Deserializes a ProphetDecomposition model from a JSON string.
164    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    /// Saves the model state to a JSON file on disk.
171    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    /// Loads a model state from a JSON file on disk.
179    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    /// Computes the logistic trend g(t) handling rate delta adjustments and offset continuity
187    #[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            // Accumulate rate changes and offset adjustments at changepoints
209            for (j, &s_j) in cps.iter().enumerate() {
210                if t >= s_j {
211                    let d = delta[j];
212                    rate += d;
213                    // Gamma ensures trend continuity across rate changes at s_j
214                    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            // Logistic curve calculation: Floor + (Cap - Floor) / (1 + exp(-(k * (t - (m + gamma)))))
223            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    /// Adds a seasonal component with a default prior scale of 10.0
231    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    /// Adds a seasonal component with a custom prior scale for independent regularization
236    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    /// Normalizes dates relative to the training period's t0 and scale horizon
256    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    /// Predicts yhat and computes percentile-based uncertainty intervals via parallel Monte Carlo simulation
336    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        // 1. Retrieve delta vector
350        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        // 2. Parallel Monte Carlo Sample Draws using Rayon
363        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                        // Inverse transform sampling for Laplace(0, b)
376                        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        // 3. Compute percentile bounds across parallel draws
403        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        // Parallel percentile extraction per timestamp t
413        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        // 4. Return ProphetPrediction with interval options
439        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        // Store training scale parameters
467        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        // Handle Logistic logit transformation or Linear pass-through
477        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        // Baseline trend endpoints from raw y
513        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            // Scale seasonal columns by the actual empirical magnitude of y(t)
527            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            // Scale seasonal/holiday features
534            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        // 1. Changepoint Regularization
542        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        // 2. Per-Seasonality Fourier Regularization
548        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        // 3. Holiday Regularization
562        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        // Solve the (regularized, positive-definite) Ridge normal equations.
569        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        // 1. Evaluate Trend Signal g(t)
598        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        // 2. Evaluate Individual Seasonalities and Holidays
623        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            // Extract each registered seasonality independently
634            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            // Extract holiday features
659            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        // 3. Compute combined yhat forecast based on seasonality mode
674        let yhat = match self.seasonality_mode {
675            SeasonalityMode::Additive => &trend + &seasonal_total + &holiday_total,
676            SeasonalityMode::Multiplicative => {
677                // seasonal_total is the fitted fractional ratio S(t)
678                &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}