Skip to main content

chronos_ts/
arima.rs

1#![allow(non_snake_case)]
2use crate::arima_poly;
3use crate::errors::{ChronosError, Result};
4use crate::linalg;
5use crate::stat_tests::{estimate_D, estimate_d};
6use crate::utils::{
7    box_cox, difference, integrate_forecast, inv_box_cox, seasonal_difference,
8    seasonal_integrate_forecast,
9};
10use argmin::core::{CostFunction, Executor, State};
11use argmin::solver::neldermead::NelderMead;
12use ndarray::{Array1, Array2};
13use rayon::prelude::*;
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct SarimaOrder {
18    pub p: usize,
19    pub d: usize,
20    pub q: usize,
21    pub P: usize,
22    pub D: usize,
23    pub Q: usize,
24    pub m: usize, // Seasonal period (e.g., 4 = quarterly, 12 = monthly, 1 = non-seasonal)
25}
26
27impl SarimaOrder {
28    /// Convenience constructor for non-seasonal ARIMA orders
29    pub fn arima(p: usize, d: usize, q: usize) -> Self {
30        Self {
31            p,
32            d,
33            q,
34            P: 0,
35            D: 0,
36            Q: 0,
37            m: 1,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ForecastResult {
44    #[serde(with = "crate::utils::serde_array1")]
45    pub mean: Array1<f64>,
46    #[serde(with = "crate::utils::serde_array1")]
47    pub lower_80: Array1<f64>,
48    #[serde(with = "crate::utils::serde_array1")]
49    pub upper_80: Array1<f64>,
50    #[serde(with = "crate::utils::serde_array1")]
51    pub lower_95: Array1<f64>,
52    #[serde(with = "crate::utils::serde_array1")]
53    pub upper_95: Array1<f64>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SarimaModel {
58    pub order: SarimaOrder,
59
60    #[serde(with = "crate::utils::serde_array1")]
61    pub ar_coeffs: Array1<f64>,
62
63    #[serde(with = "crate::utils::serde_array1")]
64    pub ma_coeffs: Array1<f64>,
65
66    #[serde(with = "crate::utils::serde_array1")]
67    pub sar_coeffs: Array1<f64>,
68
69    #[serde(with = "crate::utils::serde_array1")]
70    pub sma_coeffs: Array1<f64>,
71
72    pub sigma2: f64,
73    pub log_likelihood: f64,
74
75    /// Mean of the (differenced) series, added back during forecasting. Estimated
76    /// only when total differencing `d + D <= 1` (a mean for `d+D == 0`, a drift
77    /// for `d+D == 1`); otherwise `0.0`.
78    #[serde(default)]
79    pub intercept: f64,
80
81    /// Asymptotic standard errors of the estimated coefficients, in the order
82    /// `[ar.., ma.., sar.., sma..]`. `None` if they could not be computed.
83    #[serde(default, with = "crate::utils::serde_opt_array1")]
84    pub std_errors: Option<Array1<f64>>,
85
86    /// Box-Cox transform parameter applied to the data before fitting (`Some(0.0)`
87    /// is a log transform); forecasts are back-transformed. `None` means no transform.
88    #[serde(default)]
89    pub transform: Option<f64>,
90
91    #[serde(default, with = "crate::utils::serde_opt_array1")]
92    pub exog_beta: Option<Array1<f64>>,
93}
94
95/// Whether a constant/mean term is included for a given order. A mean is fitted
96/// when `d + D == 0`, and a drift when `d + D == 1`; higher orders of differencing
97/// would imply polynomial drift and are left mean-free (matching common practice).
98fn includes_mean(order: &SarimaOrder) -> bool {
99    order.d + order.D <= 1
100}
101
102/// Type aliases allowing callers to use Arima and Sarima interchangeably
103pub type Arima = SarimaModel;
104pub type ArimaOrder = SarimaOrder;
105
106impl SarimaModel {
107    /// Primary constructor for fitting SARIMA/ARIMA models (Conditional Sum of
108    /// Squares estimation).
109    pub fn fit(data: &Array1<f64>, order: SarimaOrder) -> Result<Self> {
110        Self::fit_with_method(data, order, EstimationMethod::Css)
111    }
112
113    /// Fits a SARIMA/ARIMA model with an explicit estimation method.
114    ///
115    /// - [`EstimationMethod::Css`] — fast Conditional Sum of Squares (default).
116    /// - [`EstimationMethod::Mle`] — exact Gaussian maximum likelihood via the
117    ///   Kalman filter; slower but statistically more efficient on short series.
118    ///   Falls back to CSS if the likelihood optimization fails.
119    pub fn fit_with_method(
120        data: &Array1<f64>,
121        order: SarimaOrder,
122        method: EstimationMethod,
123    ) -> Result<Self> {
124        Self::fit_full(data, order, method, None)
125    }
126
127    /// Fits a model to Box-Cox-transformed data (`transform` = lambda; `Some(0.0)`
128    /// is a log transform). Requires strictly positive input; forecasts produced by
129    /// the returned model are automatically back-transformed to the original scale.
130    pub fn fit_transformed(
131        data: &Array1<f64>,
132        order: SarimaOrder,
133        method: EstimationMethod,
134        transform: Option<f64>,
135    ) -> Result<Self> {
136        Self::fit_full(data, order, method, transform)
137    }
138
139    fn fit_full(
140        data: &Array1<f64>,
141        order: SarimaOrder,
142        method: EstimationMethod,
143        transform: Option<f64>,
144    ) -> Result<Self> {
145        validate_series(data)?;
146
147        // Optional Box-Cox transform (requires positive data).
148        let work = match transform {
149            Some(lambda) => {
150                if data.iter().any(|&x| x <= 0.0) {
151                    return Err(ChronosError::InvalidParameters(
152                        "Box-Cox transform requires strictly positive data.".into(),
153                    ));
154                }
155                box_cox(data, lambda)
156            }
157            None => data.clone(),
158        };
159
160        let min_required =
161            order.p + order.q + order.d + (order.P + order.Q + order.D) * order.m.max(1);
162        if work.len() <= min_required {
163            return Err(ChronosError::InsufficientData {
164                required: min_required + 1,
165                found: work.len(),
166            });
167        }
168
169        // 1. Non-seasonal differencing
170        let mut transformed = difference(&work, order.d);
171
172        // 2. Seasonal differencing (skipped if m <= 1 or D == 0)
173        if order.m > 1 && order.D > 0 {
174            transformed = seasonal_difference(&transformed, order.m, order.D);
175        }
176
177        let n = transformed.len();
178
179        // Center the (differenced) series by its mean so the ARMA part is fitted on
180        // a zero-mean series; the mean is added back when forecasting. This gives the
181        // model an intercept (d+D == 0) or a drift term (d+D == 1).
182        let intercept = if includes_mean(&order) {
183            transformed.mean().unwrap_or(0.0)
184        } else {
185            0.0
186        };
187        let centered = if intercept != 0.0 {
188            &transformed - intercept
189        } else {
190            transformed.clone()
191        };
192
193        // CSS estimate (also the fallback for a failed MLE run).
194        let css = |order: &SarimaOrder| {
195            let (ar, ma, sar, sma) = estimate_css(&centered, order);
196            let (_residuals, sse) = Self::compute_residuals(&centered, &ar, &ma, &sar, &sma, order);
197            let sigma2 = (sse / (n as f64)).max(1e-8);
198            let log_like = -0.5 * (n as f64) * ((2.0 * std::f64::consts::PI * sigma2).ln() + 1.0);
199            (ar, ma, sar, sma, sigma2, log_like)
200        };
201
202        let (ar_coeffs, ma_coeffs, sar_coeffs, sma_coeffs, sigma2, log_like) = match method {
203            EstimationMethod::Css => css(&order),
204            EstimationMethod::Mle => match crate::arima_mle::estimate_mle(&centered, &order) {
205                Some(est) => (
206                    est.ar,
207                    est.ma,
208                    est.sar,
209                    est.sma,
210                    est.sigma2.max(1e-8),
211                    est.log_likelihood,
212                ),
213                None => css(&order),
214            },
215        };
216
217        // Asymptotic coefficient standard errors from the (concentrated) objective's
218        // numerical Hessian. Both estimators share the same Gaussian objective form.
219        let params = concat_params(&ar_coeffs, &ma_coeffs, &sar_coeffs, &sma_coeffs);
220        let std_errors = hessian_std_errors(
221            |p| {
222                let (ar, ma, sar, sma) = split_params(p, &order);
223                let (_res, sse) = Self::compute_residuals(&centered, &ar, &ma, &sar, &sma, &order);
224                0.5 * (n as f64) * sse.max(1e-12).ln()
225            },
226            &params,
227        );
228
229        Ok(Self {
230            order,
231            ar_coeffs,
232            ma_coeffs,
233            sar_coeffs,
234            sma_coeffs,
235            sigma2,
236            log_likelihood: log_like,
237            intercept,
238            std_errors,
239            transform,
240            exog_beta: None,
241        })
242    }
243
244    /// Returns the model's in-sample one-step innovation residuals (on the
245    /// differenced, mean-centered scale the ARMA part was fitted on). These feed the
246    /// residual diagnostics in [`crate::diagnostics`].
247    pub fn residuals(&self, data: &Array1<f64>) -> Array1<f64> {
248        let work = match self.transform {
249            Some(lambda) => box_cox(data, lambda),
250            None => data.clone(),
251        };
252        let mut w = difference(&work, self.order.d);
253        if self.order.m > 1 && self.order.D > 0 {
254            w = seasonal_difference(&w, self.order.m, self.order.D);
255        }
256        let centered = if self.intercept != 0.0 {
257            &w - self.intercept
258        } else {
259            w
260        };
261        let (residuals, _sse) = Self::compute_residuals(
262            &centered,
263            &self.ar_coeffs,
264            &self.ma_coeffs,
265            &self.sar_coeffs,
266            &self.sma_coeffs,
267            &self.order,
268        );
269        residuals
270    }
271
272    /// Fits a SARIMAX model. If `exog` is provided, it first removes the linear trend
273    /// contributed by X and then fits the SARIMA parameters on the regression residuals.
274    pub fn fit_with_exog(
275        y: &Array1<f64>,
276        exog: Option<&Array2<f64>>,
277        order: SarimaOrder,
278    ) -> Result<Self> {
279        // Fixed: Changed return type to crate Result<Self>
280        let (y_residuals, exog_beta) = if let Some(x) = exog {
281            if x.nrows() != y.len() {
282                return Err(ChronosError::ConvergenceFailure(
283                    "Exogenous matrix row count must match target array length.".into(),
284                ));
285            }
286            let beta = fit_ols(x, y)?;
287            let residuals = y - &x.dot(&beta);
288            (residuals, Some(beta))
289        } else {
290            (y.clone(), None)
291        };
292
293        // Fit standard SARIMA on residual time series
294        let mut model = Self::fit(&y_residuals, order)?;
295        model.exog_beta = exog_beta;
296
297        Ok(model)
298    }
299
300    /// Forecasts a SARIMAX model fitted via [`Self::fit_with_exog`].
301    ///
302    /// The SARIMA dynamics were estimated on the regression residuals
303    /// `eta = y - X * beta`, so forecasting requires the historical exogenous
304    /// matrix `exog_hist` to reconstruct that residual series, then adds the
305    /// future exogenous contribution `X_fut * beta` back onto every band.
306    ///
307    /// For a model fitted without exogenous variables this reduces to
308    /// [`Self::forecast_with_intervals`]; `exog_hist`/`exog_future` are ignored.
309    pub fn forecast_with_intervals_exog(
310        &self,
311        data: &Array1<f64>,
312        exog_hist: Option<&Array2<f64>>,
313        exog_future: Option<&Array2<f64>>,
314        steps: usize,
315    ) -> Result<ForecastResult> {
316        // Reconstruct the residual series the SARIMA part was actually fitted on.
317        let residual_series = match (&self.exog_beta, exog_hist) {
318            (Some(beta), Some(x_hist)) => {
319                if x_hist.nrows() != data.len() {
320                    return Err(ChronosError::ConvergenceFailure(
321                        "Historical exogenous matrix rows must match the data length.".into(),
322                    ));
323                }
324                if x_hist.ncols() != beta.len() {
325                    return Err(ChronosError::ConvergenceFailure(
326                        "Historical exogenous matrix columns must match fitted beta length.".into(),
327                    ));
328                }
329                data - &x_hist.dot(beta)
330            }
331            (Some(_), None) => {
332                return Err(ChronosError::ConvergenceFailure(
333                    "Model was fitted with exogenous features; exog_hist is required to forecast."
334                        .into(),
335                ));
336            }
337            (None, _) => data.clone(),
338        };
339
340        // Base SARIMA point forecast and prediction intervals on the residual series.
341        let mut forecast = self.forecast_with_intervals(&residual_series, steps);
342
343        // Add the future exogenous contribution: y_fut = eta_fut + X_fut * beta.
344        if let (Some(beta), Some(x_fut)) = (&self.exog_beta, exog_future) {
345            if x_fut.nrows() != steps {
346                return Err(ChronosError::ConvergenceFailure(
347                    "Future exogenous matrix rows must equal requested forecast steps.".into(),
348                ));
349            }
350            if x_fut.ncols() != beta.len() {
351                return Err(ChronosError::ConvergenceFailure(
352                    "Future exogenous matrix columns must match fitted beta length.".into(),
353                ));
354            }
355            let exog_impact = x_fut.dot(beta);
356
357            forecast.mean += &exog_impact;
358            forecast.lower_80 += &exog_impact;
359            forecast.upper_80 += &exog_impact;
360            forecast.lower_95 += &exog_impact;
361            forecast.upper_95 += &exog_impact;
362        } else if self.exog_beta.is_some() {
363            return Err(ChronosError::ConvergenceFailure(
364                "Model was fitted with exogenous features; exog_future is required to forecast."
365                    .into(),
366            ));
367        }
368
369        Ok(forecast)
370    }
371
372    pub fn forecast_with_intervals(&self, data: &Array1<f64>, steps: usize) -> ForecastResult {
373        // Forecast and build intervals on the working (possibly Box-Cox) scale.
374        let work = match self.transform {
375            Some(lambda) => box_cox(data, lambda),
376            None => data.clone(),
377        };
378        let mean_w = self.forecast_core(&work, steps);
379
380        let mut lower_80 = Array1::zeros(steps);
381        let mut upper_80 = Array1::zeros(steps);
382        let mut lower_95 = Array1::zeros(steps);
383        let mut upper_95 = Array1::zeros(steps);
384
385        // Proper ARIMA h-step forecast variance: sigma^2 * sum_{j<h} psi_j^2, where
386        // the psi-weights are the MA(inf) representation of the full model with the
387        // differencing operators folded into the AR side (so the intervals are on
388        // the integrated working scale).
389        let ar_poly = arima_poly::ar_polynomial(
390            &self.order,
391            &self.ar_coeffs.to_vec(),
392            &self.sar_coeffs.to_vec(),
393            true,
394        );
395        let ma_poly = arima_poly::ma_polynomial(
396            &self.order,
397            &self.ma_coeffs.to_vec(),
398            &self.sma_coeffs.to_vec(),
399        );
400        let psi = arima_poly::psi_weights(&ar_poly, &ma_poly, steps.saturating_sub(1));
401
402        let mut var_accum = 0.0;
403        for h in 0..steps {
404            var_accum += self.sigma2 * psi[h] * psi[h];
405            let se = var_accum.sqrt();
406            lower_80[h] = mean_w[h] - 1.282 * se;
407            upper_80[h] = mean_w[h] + 1.282 * se;
408            lower_95[h] = mean_w[h] - 1.960 * se;
409            upper_95[h] = mean_w[h] + 1.960 * se;
410        }
411
412        // Back-transform mean and bounds. The Box-Cox inverse is monotone increasing,
413        // so it preserves the lower <= mean <= upper ordering.
414        match self.transform {
415            Some(lambda) => ForecastResult {
416                mean: inv_box_cox(&mean_w, lambda),
417                lower_80: inv_box_cox(&lower_80, lambda),
418                upper_80: inv_box_cox(&upper_80, lambda),
419                lower_95: inv_box_cox(&lower_95, lambda),
420                upper_95: inv_box_cox(&upper_95, lambda),
421            },
422            None => ForecastResult {
423                mean: mean_w,
424                lower_80,
425                upper_80,
426                lower_95,
427                upper_95,
428            },
429        }
430    }
431
432    /// Convenience shortcut for non-seasonal ARIMA
433    pub fn fit_arima(data: &Array1<f64>, p: usize, d: usize, q: usize) -> Result<Self> {
434        Self::fit(data, SarimaOrder::arima(p, d, q))
435    }
436
437    /// Returns true if the fitted model is purely non-seasonal
438    pub fn is_pure_arima(&self) -> bool {
439        self.order.P == 0 && self.order.D == 0 && self.order.Q == 0
440    }
441
442    /// Recursively computes multiplicative SARIMA residuals:
443    /// phi(B) Phi(B^m) (1-B)^d (1-B^m)^D Y_t = theta(B) Theta(B^m) epsilon_t
444    fn compute_residuals(
445        data: &Array1<f64>,
446        ar: &Array1<f64>,
447        ma: &Array1<f64>,
448        sar: &Array1<f64>,
449        sma: &Array1<f64>,
450        order: &SarimaOrder,
451    ) -> (Array1<f64>, f64) {
452        let n = data.len();
453        let mut residuals = Array1::zeros(n);
454        let mut sse = 0.0;
455        let m = order.m.max(1);
456
457        for t in 0..n {
458            let mut pred = 0.0;
459
460            // Non-seasonal AR
461            for i in 0..order.p {
462                if t > i {
463                    pred += ar[i] * data[t - i - 1];
464                }
465            }
466
467            // Seasonal AR
468            for i in 0..order.P {
469                if t >= (i + 1) * m {
470                    pred += sar[i] * data[t - (i + 1) * m];
471                }
472            }
473
474            // Non-seasonal MA
475            for j in 0..order.q {
476                if t > j {
477                    pred += ma[j] * residuals[t - j - 1];
478                }
479            }
480
481            // Seasonal MA
482            for j in 0..order.Q {
483                if t >= (j + 1) * m {
484                    pred += sma[j] * residuals[t - (j + 1) * m];
485                }
486            }
487
488            let res = data[t] - pred;
489            residuals[t] = res;
490
491            let burn_in = order.p + order.q + (order.P + order.Q) * m;
492            if t >= burn_in {
493                sse += res.powi(2);
494            }
495        }
496
497        (residuals, sse)
498    }
499
500    /// Number of estimated parameters: ARMA/seasonal coefficients, plus the mean
501    /// (when included) plus the innovation variance.
502    fn num_params(&self) -> f64 {
503        let mean = usize::from(includes_mean(&self.order));
504        (self.order.p + self.order.q + self.order.P + self.order.Q + mean + 1) as f64
505    }
506
507    pub fn aic(&self) -> f64 {
508        let k = self.num_params();
509        2.0 * k - 2.0 * self.log_likelihood
510    }
511
512    pub fn aicc(&self, n: usize) -> f64 {
513        let k = self.num_params();
514        let aic = self.aic();
515        if (n as f64 - k - 1.0) <= 0.0 {
516            return f64::INFINITY;
517        }
518        aic + (2.0 * k * (k + 1.0)) / (n as f64 - k - 1.0)
519    }
520
521    pub fn bic(&self, n: usize) -> f64 {
522        let k = self.num_params();
523        k * (n as f64).ln() - 2.0 * self.log_likelihood
524    }
525
526    /// Point forecast `steps` periods ahead. If the model was fitted with a Box-Cox
527    /// transform, the forecast is automatically back-transformed to the original scale.
528    pub fn forecast(&self, history: &Array1<f64>, steps: usize) -> Array1<f64> {
529        let work = match self.transform {
530            Some(lambda) => box_cox(history, lambda),
531            None => history.clone(),
532        };
533        let fc = self.forecast_core(&work, steps);
534        match self.transform {
535            Some(lambda) => inv_box_cox(&fc, lambda),
536            None => fc,
537        }
538    }
539
540    /// Point forecast on the model's working scale (Box-Cox already applied, if any).
541    fn forecast_core(&self, history: &Array1<f64>, steps: usize) -> Array1<f64> {
542        // z = non-seasonally differenced history; w = z after seasonal differencing.
543        let z_hist = difference(history, self.order.d);
544        let seasonal = self.order.m > 1 && self.order.D > 0;
545        let w_hist = if seasonal {
546            seasonal_difference(&z_hist, self.order.m, self.order.D)
547        } else {
548            z_hist.clone()
549        };
550
551        // The ARMA part was fitted on the mean-centered w series.
552        let centered = if self.intercept != 0.0 {
553            &w_hist - self.intercept
554        } else {
555            w_hist.clone()
556        };
557
558        // Reconstruct in-sample residuals so MA/seasonal-MA terms drive the first
559        // `q` (and `Q*m`) forecast steps; future innovations have expectation zero.
560        let (residuals, _sse) = Self::compute_residuals(
561            &centered,
562            &self.ar_coeffs,
563            &self.ma_coeffs,
564            &self.sar_coeffs,
565            &self.sma_coeffs,
566            &self.order,
567        );
568
569        let m = self.order.m.max(1);
570        let mut vals = centered.to_vec();
571        let mut res = residuals.to_vec();
572        let mut w_forecasts = Vec::with_capacity(steps);
573
574        for _ in 0..steps {
575            let t = vals.len();
576            let mut f_val = 0.0;
577
578            for i in 0..self.order.p {
579                if t > i {
580                    f_val += self.ar_coeffs[i] * vals[t - 1 - i];
581                }
582            }
583            for i in 0..self.order.P {
584                if t >= (i + 1) * m {
585                    f_val += self.sar_coeffs[i] * vals[t - (i + 1) * m];
586                }
587            }
588            for j in 0..self.order.q {
589                if t > j {
590                    f_val += self.ma_coeffs[j] * res[t - 1 - j];
591                }
592            }
593            for j in 0..self.order.Q {
594                if t >= (j + 1) * m {
595                    f_val += self.sma_coeffs[j] * res[t - (j + 1) * m];
596                }
597            }
598
599            vals.push(f_val);
600            res.push(0.0); // expected value of a future innovation is zero
601                           // Add the mean back to move from the centered scale to the w scale.
602            w_forecasts.push(f_val + self.intercept);
603        }
604
605        // Undo seasonal differencing, then non-seasonal differencing.
606        let w_forecasts = Array1::from(w_forecasts);
607        let z_forecasts = if seasonal {
608            seasonal_integrate_forecast(&w_forecasts, &z_hist, self.order.m, self.order.D)
609        } else {
610            w_forecasts
611        };
612        integrate_forecast(&z_forecasts, history, self.order.d)
613    }
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum InformationCriterion {
618    Aic,
619    Aicc,
620    Bic,
621}
622
623/// How ARIMA coefficients are estimated.
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
625pub enum EstimationMethod {
626    /// Conditional Sum of Squares: fast, robust, the default.
627    #[default]
628    Css,
629    /// Exact Gaussian maximum likelihood via the Kalman filter: slower but more
630    /// statistically efficient, especially on short series.
631    Mle,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub struct AutoArimaOptions {
636    pub max_p: usize,
637    pub max_q: usize,
638    pub max_P: usize,
639    pub max_Q: usize,
640    pub max_d: usize,
641    pub max_D: usize,
642    pub m: usize,
643    pub criterion: InformationCriterion,
644    pub stepwise: bool,
645    pub estimation: EstimationMethod,
646}
647
648impl Default for AutoArimaOptions {
649    fn default() -> Self {
650        Self {
651            max_p: 5,
652            max_q: 5,
653            max_P: 2,
654            max_Q: 2,
655            max_d: 2,
656            max_D: 1,
657            m: 1,
658            criterion: InformationCriterion::Aicc,
659            stepwise: true,
660            estimation: EstimationMethod::Css,
661        }
662    }
663}
664
665/// Minimum batch size of candidate models required to justify Rayon thread-pool overhead.
666const PARALLEL_BATCH_THRESHOLD: usize = 8;
667
668/// Fits linear regression y = X * beta and returns beta coefficients.
669fn fit_ols(x: &Array2<f64>, y: &Array1<f64>) -> Result<Array1<f64>> {
670    linalg::lstsq(x, y).map_err(|e| {
671        ChronosError::ConvergenceFailure(format!(
672            "Failed to solve OLS for exogenous variables: {}",
673            e
674        ))
675    })
676}
677
678/// Splits a flat CSS parameter vector into (AR, MA, seasonal-AR, seasonal-MA) blocks.
679/// Rejects empty series and series containing non-finite (NaN/inf) values.
680fn validate_series(data: &Array1<f64>) -> Result<()> {
681    if data.is_empty() {
682        return Err(ChronosError::InvalidParameters(
683            "Input series is empty.".into(),
684        ));
685    }
686    if data.iter().any(|x| !x.is_finite()) {
687        return Err(ChronosError::InvalidParameters(
688            "Input series contains non-finite (NaN/inf) values.".into(),
689        ));
690    }
691    Ok(())
692}
693
694/// Concatenates coefficient blocks into a single parameter vector `[ar, ma, sar, sma]`.
695fn concat_params(
696    ar: &Array1<f64>,
697    ma: &Array1<f64>,
698    sar: &Array1<f64>,
699    sma: &Array1<f64>,
700) -> Vec<f64> {
701    ar.iter()
702        .chain(ma.iter())
703        .chain(sar.iter())
704        .chain(sma.iter())
705        .copied()
706        .collect()
707}
708
709/// Asymptotic coefficient standard errors from a scalar objective (a negative
710/// log-likelihood) evaluated at its optimum `x`: the covariance is the inverse of
711/// the numerical Hessian; the standard errors are the square roots of its diagonal.
712/// Returns `None` if the Hessian is singular or yields negative variances.
713pub(crate) fn hessian_std_errors<F: Fn(&[f64]) -> f64>(f: F, x: &[f64]) -> Option<Array1<f64>> {
714    let k = x.len();
715    if k == 0 {
716        return Some(Array1::zeros(0));
717    }
718
719    let h = 1e-4;
720    let f0 = f(x);
721    let mut hess = Array2::<f64>::zeros((k, k));
722    let mut probe = x.to_vec();
723
724    for i in 0..k {
725        for j in i..k {
726            let value = if i == j {
727                probe[i] = x[i] + h;
728                let fp = f(&probe);
729                probe[i] = x[i] - h;
730                let fm = f(&probe);
731                probe[i] = x[i];
732                (fp - 2.0 * f0 + fm) / (h * h)
733            } else {
734                probe[i] = x[i] + h;
735                probe[j] = x[j] + h;
736                let fpp = f(&probe);
737                probe[j] = x[j] - h;
738                let fpm = f(&probe);
739                probe[i] = x[i] - h;
740                let fmm = f(&probe);
741                probe[j] = x[j] + h;
742                let fmp = f(&probe);
743                probe[i] = x[i];
744                probe[j] = x[j];
745                (fpp - fpm - fmp + fmm) / (4.0 * h * h)
746            };
747            hess[[i, j]] = value;
748            hess[[j, i]] = value;
749        }
750    }
751
752    let cov = linalg::inv(&hess).ok()?;
753    let mut se = Array1::zeros(k);
754    for i in 0..k {
755        let var = cov[[i, i]];
756        if !var.is_finite() || var < 0.0 {
757            return None;
758        }
759        se[i] = var.sqrt();
760    }
761    Some(se)
762}
763
764pub(crate) fn split_params(
765    params: &[f64],
766    order: &SarimaOrder,
767) -> (Array1<f64>, Array1<f64>, Array1<f64>, Array1<f64>) {
768    let mut idx = 0;
769    let ar = Array1::from(params[idx..idx + order.p].to_vec());
770    idx += order.p;
771    let ma = Array1::from(params[idx..idx + order.q].to_vec());
772    idx += order.q;
773    let sar = Array1::from(params[idx..idx + order.P].to_vec());
774    idx += order.P;
775    let sma = Array1::from(params[idx..idx + order.Q].to_vec());
776    (ar, ma, sar, sma)
777}
778
779/// argmin cost wrapping the Conditional Sum of Squares objective for a fixed order.
780struct CssCost<'a> {
781    data: &'a Array1<f64>,
782    order: SarimaOrder,
783}
784
785impl<'a> CostFunction for CssCost<'a> {
786    type Param = Vec<f64>;
787    type Output = f64;
788
789    fn cost(&self, params: &Self::Param) -> std::result::Result<Self::Output, argmin::core::Error> {
790        let (ar, ma, sar, sma) = split_params(params, &self.order);
791        let (_res, sse) =
792            SarimaModel::compute_residuals(self.data, &ar, &ma, &sar, &sma, &self.order);
793        if sse.is_finite() {
794            Ok(sse)
795        } else {
796            Ok(f64::INFINITY)
797        }
798    }
799}
800
801/// Estimates SARIMA coefficients by Conditional Sum of Squares.
802///
803/// Uses a derivative-free Nelder-Mead search. The CSS objective is smooth for
804/// pure-AR models but strongly nonlinear once moving-average terms are present
805/// (residuals recurse through the coefficients), where gradient/line-search
806/// methods stall at the origin; Nelder-Mead is robust across both cases.
807///
808/// Returns zero-length arrays for any block whose order is zero, and falls back
809/// to zero coefficients if the optimizer fails (e.g. degenerate/constant input)
810/// so that model fitting never panics.
811fn estimate_css(
812    data: &Array1<f64>,
813    order: &SarimaOrder,
814) -> (Array1<f64>, Array1<f64>, Array1<f64>, Array1<f64>) {
815    let k = order.p + order.q + order.P + order.Q;
816    let fallback = || {
817        (
818            Array1::zeros(order.p),
819            Array1::zeros(order.q),
820            Array1::zeros(order.P),
821            Array1::zeros(order.Q),
822        )
823    };
824
825    // Pure white-noise / random-walk model: nothing to estimate.
826    if k == 0 {
827        return fallback();
828    }
829
830    // Initial simplex: the origin plus one perturbed vertex per parameter.
831    let mut simplex: Vec<Vec<f64>> = Vec::with_capacity(k + 1);
832    simplex.push(vec![0.0; k]);
833    for i in 0..k {
834        let mut vertex = vec![0.0; k];
835        vertex[i] = 0.3;
836        simplex.push(vertex);
837    }
838
839    let cost = CssCost {
840        data,
841        order: *order,
842    };
843
844    let solver = match NelderMead::new(simplex).with_sd_tolerance(1e-9) {
845        Ok(s) => s,
846        Err(_) => return fallback(),
847    };
848
849    let outcome = Executor::new(cost, solver)
850        .configure(|state| state.max_iters(1000))
851        .run();
852
853    match outcome {
854        Ok(exec) => match exec.state.get_best_param() {
855            Some(best) => {
856                let (ar, ma, sar, sma) = split_params(best, order);
857                stabilize(order, ar, ma, sar, sma)
858            }
859            None => fallback(),
860        },
861        Err(_) => fallback(),
862    }
863}
864
865/// Keeps an estimated coefficient set stationary/stable. If the fitted AR part is
866/// explosive, coefficients are shrunk geometrically toward zero until the
867/// impulse-response is bounded; if that fails, they are zeroed. This is a safety
868/// net against pathological CSS optima on difficult data — well-behaved fits pass
869/// through unchanged.
870fn stabilize(
871    order: &SarimaOrder,
872    mut ar: Array1<f64>,
873    mut ma: Array1<f64>,
874    mut sar: Array1<f64>,
875    mut sma: Array1<f64>,
876) -> (Array1<f64>, Array1<f64>, Array1<f64>, Array1<f64>) {
877    let is_stable = |ar: &Array1<f64>, ma: &Array1<f64>, sar: &Array1<f64>, sma: &Array1<f64>| {
878        let ar_poly = arima_poly::ar_polynomial(order, &ar.to_vec(), &sar.to_vec(), false);
879        let ma_poly = arima_poly::ma_polynomial(order, &ma.to_vec(), &sma.to_vec());
880        // Require both AR stationarity and MA invertibility.
881        arima_poly::is_stable(&ar_poly, &ma_poly) && arima_poly::is_invertible(&ma_poly)
882    };
883
884    if is_stable(&ar, &ma, &sar, &sma) {
885        return (ar, ma, sar, sma);
886    }
887
888    for _ in 0..40 {
889        ar.mapv_inplace(|c| c * 0.9);
890        ma.mapv_inplace(|c| c * 0.9);
891        sar.mapv_inplace(|c| c * 0.9);
892        sma.mapv_inplace(|c| c * 0.9);
893        if is_stable(&ar, &ma, &sar, &sma) {
894            return (ar, ma, sar, sma);
895        }
896    }
897
898    // Give up: zero coefficients are trivially stable.
899    (
900        Array1::zeros(order.p),
901        Array1::zeros(order.q),
902        Array1::zeros(order.P),
903        Array1::zeros(order.Q),
904    )
905}
906
907/// Aggregate accuracy from an ARIMA rolling-origin cross-validation.
908#[derive(Debug, Clone, Copy, PartialEq)]
909pub struct ArimaCvReport {
910    pub mae: f64,
911    pub rmse: f64,
912    pub mape: f64,
913    pub n_windows: usize,
914    pub horizon: usize,
915}
916
917/// Rolling-origin (expanding-window) cross-validation for a fixed ARIMA order.
918///
919/// Starting from an initial training window, the model is refit and forecast
920/// `horizon` steps ahead, the origin advanced by `step`, and forecast errors
921/// accumulated across all windows. Returns MAE / RMSE / MAPE over every
922/// (window, step-ahead) pair.
923pub fn arima_cross_validation(
924    data: &Array1<f64>,
925    order: SarimaOrder,
926    method: EstimationMethod,
927    initial: usize,
928    horizon: usize,
929    step: usize,
930) -> Result<ArimaCvReport> {
931    validate_series(data)?;
932    let n = data.len();
933    let step = step.max(1);
934
935    if horizon == 0 {
936        return Err(ChronosError::InvalidParameters(
937            "Cross-validation horizon must be at least 1.".into(),
938        ));
939    }
940    if initial + horizon > n {
941        return Err(ChronosError::InsufficientData {
942            required: initial + horizon,
943            found: n,
944        });
945    }
946
947    let mut abs_sum = 0.0;
948    let mut sq_sum = 0.0;
949    let mut pct_sum = 0.0;
950    let mut pct_count = 0usize;
951    let mut count = 0usize;
952    let mut n_windows = 0usize;
953
954    let mut train_end = initial;
955    while train_end + horizon <= n {
956        let train = data.slice(ndarray::s![..train_end]).to_owned();
957        if let Ok(model) = SarimaModel::fit_with_method(&train, order, method) {
958            let fc = model.forecast(&train, horizon);
959            for h in 0..horizon {
960                let actual = data[train_end + h];
961                let err = fc[h] - actual;
962                abs_sum += err.abs();
963                sq_sum += err * err;
964                if actual.abs() > 1e-8 {
965                    pct_sum += (err / actual).abs();
966                    pct_count += 1;
967                }
968                count += 1;
969            }
970            n_windows += 1;
971        }
972        train_end += step;
973    }
974
975    if count == 0 {
976        return Err(ChronosError::ConvergenceFailure(
977            "Cross-validation produced no valid forecasts.".into(),
978        ));
979    }
980
981    let denom = count as f64;
982    Ok(ArimaCvReport {
983        mae: abs_sum / denom,
984        rmse: (sq_sum / denom).sqrt(),
985        mape: if pct_count > 0 {
986            100.0 * pct_sum / pct_count as f64
987        } else {
988            f64::NAN
989        },
990        n_windows,
991        horizon,
992    })
993}
994
995/// Executes automatic model selection using statistical unit-root tests and stepwise IC optimization
996pub fn auto_arima(data: &Array1<f64>, opts: AutoArimaOptions) -> Result<SarimaModel> {
997    validate_series(data)?;
998    let n = data.len();
999
1000    // 1. Determine d and D using stationarity tests
1001    let d = estimate_d(data, opts.max_d, 0.05);
1002    let D = estimate_D(data, opts.m, opts.max_D);
1003
1004    // Seasonal orders are only meaningful with a real seasonal period. When
1005    // m <= 1 the seasonal P/D/Q terms operate at the same lags as the
1006    // non-seasonal ones (aliasing/degenerate), so disable them entirely.
1007    let seasonal = opts.m > 1;
1008    let max_P = if seasonal { opts.max_P } else { 0 };
1009    let max_Q = if seasonal { opts.max_Q } else { 0 };
1010    let seed_seasonal = usize::from(seasonal);
1011
1012    let get_ic = |model: &SarimaModel| match opts.criterion {
1013        InformationCriterion::Aic => model.aic(),
1014        InformationCriterion::Aicc => model.aicc(n),
1015        InformationCriterion::Bic => model.bic(n),
1016    };
1017
1018    let method = opts.estimation;
1019
1020    // Helper closure to evaluate a batch of orders sequentially
1021    let eval_sequential = |orders: &[SarimaOrder]| {
1022        orders
1023            .iter()
1024            .filter_map(|&order| {
1025                SarimaModel::fit_with_method(data, order, method)
1026                    .ok()
1027                    .map(|model| (get_ic(&model), model))
1028            })
1029            .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1030    };
1031
1032    // Helper closure to evaluate a batch of orders in parallel via Rayon
1033    let eval_parallel = |orders: &[SarimaOrder]| {
1034        orders
1035            .par_iter()
1036            .filter_map(|&order| {
1037                SarimaModel::fit_with_method(data, order, method)
1038                    .ok()
1039                    .map(|model| (get_ic(&model), model))
1040            })
1041            .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1042    };
1043
1044    // Smart evaluator that applies Strategy 2
1045    let eval_orders = |orders: &[SarimaOrder]| {
1046        if orders.len() >= PARALLEL_BATCH_THRESHOLD {
1047            eval_parallel(orders)
1048        } else {
1049            eval_sequential(orders)
1050        }
1051    };
1052
1053    // -------------------------------------------------------------
1054    // Full Grid Search Mode
1055    // -------------------------------------------------------------
1056    if !opts.stepwise {
1057        let mut orders = Vec::new();
1058        for p in 0..=opts.max_p {
1059            for q in 0..=opts.max_q {
1060                for P in 0..=max_P {
1061                    for Q in 0..=max_Q {
1062                        orders.push(SarimaOrder {
1063                            p,
1064                            d,
1065                            q,
1066                            P,
1067                            D,
1068                            Q,
1069                            m: opts.m,
1070                        });
1071                    }
1072                }
1073            }
1074        }
1075
1076        return eval_orders(&orders)
1077            .map(|(_, model)| model)
1078            .ok_or_else(|| ChronosError::ConvergenceFailure("Failed grid model fitting".into()));
1079    }
1080
1081    // -------------------------------------------------------------
1082    // Stepwise Heuristic Optimization Mode
1083    // -------------------------------------------------------------
1084    let seed_orders = vec![
1085        SarimaOrder {
1086            p: 2,
1087            d,
1088            q: 2,
1089            P: seed_seasonal,
1090            D,
1091            Q: seed_seasonal,
1092            m: opts.m,
1093        },
1094        SarimaOrder {
1095            p: 0,
1096            d,
1097            q: 0,
1098            P: 0,
1099            D,
1100            Q: 0,
1101            m: opts.m,
1102        },
1103        SarimaOrder {
1104            p: 1,
1105            d,
1106            q: 0,
1107            P: seed_seasonal,
1108            D,
1109            Q: 0,
1110            m: opts.m,
1111        },
1112        SarimaOrder {
1113            p: 0,
1114            d,
1115            q: 1,
1116            P: 0,
1117            D,
1118            Q: seed_seasonal,
1119            m: opts.m,
1120        },
1121    ];
1122
1123    let (mut best_score, mut current_model) = eval_orders(&seed_orders).ok_or_else(|| {
1124        ChronosError::ConvergenceFailure("Failed fitting initial seed models".into())
1125    })?;
1126
1127    let mut improved = true;
1128    while improved {
1129        improved = false;
1130        let curr_o = current_model.order;
1131        let mut candidates = Vec::new();
1132
1133        if curr_o.p < opts.max_p {
1134            candidates.push(SarimaOrder {
1135                p: curr_o.p + 1,
1136                ..curr_o
1137            });
1138        }
1139        if curr_o.p > 0 {
1140            candidates.push(SarimaOrder {
1141                p: curr_o.p - 1,
1142                ..curr_o
1143            });
1144        }
1145        if curr_o.q < opts.max_q {
1146            candidates.push(SarimaOrder {
1147                q: curr_o.q + 1,
1148                ..curr_o
1149            });
1150        }
1151        if curr_o.q > 0 {
1152            candidates.push(SarimaOrder {
1153                q: curr_o.q - 1,
1154                ..curr_o
1155            });
1156        }
1157
1158        // Seasonal perturbations (only when a seasonal period is configured).
1159        if curr_o.P < max_P {
1160            candidates.push(SarimaOrder {
1161                P: curr_o.P + 1,
1162                ..curr_o
1163            });
1164        }
1165        if curr_o.P > 0 {
1166            candidates.push(SarimaOrder {
1167                P: curr_o.P - 1,
1168                ..curr_o
1169            });
1170        }
1171        if curr_o.Q < max_Q {
1172            candidates.push(SarimaOrder {
1173                Q: curr_o.Q + 1,
1174                ..curr_o
1175            });
1176        }
1177        if curr_o.Q > 0 {
1178            candidates.push(SarimaOrder {
1179                Q: curr_o.Q - 1,
1180                ..curr_o
1181            });
1182        }
1183
1184        if let Some((cand_score, cand_model)) = eval_orders(&candidates) {
1185            if cand_score < best_score {
1186                best_score = cand_score;
1187                current_model = cand_model;
1188                improved = true;
1189            }
1190        }
1191    }
1192
1193    Ok(current_model)
1194}