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, }
26
27impl SarimaOrder {
28 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 #[serde(default)]
79 pub intercept: f64,
80
81 #[serde(default, with = "crate::utils::serde_opt_array1")]
84 pub std_errors: Option<Array1<f64>>,
85
86 #[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
95fn includes_mean(order: &SarimaOrder) -> bool {
99 order.d + order.D <= 1
100}
101
102pub type Arima = SarimaModel;
104pub type ArimaOrder = SarimaOrder;
105
106impl SarimaModel {
107 pub fn fit(data: &Array1<f64>, order: SarimaOrder) -> Result<Self> {
110 Self::fit_with_method(data, order, EstimationMethod::Css)
111 }
112
113 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 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 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 let mut transformed = difference(&work, order.d);
171
172 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 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 let css = |order: &SarimaOrder| {
195 let (ar, ma, sar, sma) = estimate_css(¢ered, order);
196 let (_residuals, sse) = Self::compute_residuals(¢ered, &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(¢ered, &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 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(¢ered, &ar, &ma, &sar, &sma, &order);
224 0.5 * (n as f64) * sse.max(1e-12).ln()
225 },
226 ¶ms,
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 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 ¢ered,
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 pub fn fit_with_exog(
275 y: &Array1<f64>,
276 exog: Option<&Array2<f64>>,
277 order: SarimaOrder,
278 ) -> Result<Self> {
279 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 let mut model = Self::fit(&y_residuals, order)?;
295 model.exog_beta = exog_beta;
296
297 Ok(model)
298 }
299
300 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 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 let mut forecast = self.forecast_with_intervals(&residual_series, steps);
342
343 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 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 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 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 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 pub fn is_pure_arima(&self) -> bool {
439 self.order.P == 0 && self.order.D == 0 && self.order.Q == 0
440 }
441
442 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 for i in 0..order.p {
462 if t > i {
463 pred += ar[i] * data[t - i - 1];
464 }
465 }
466
467 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 for j in 0..order.q {
476 if t > j {
477 pred += ma[j] * residuals[t - j - 1];
478 }
479 }
480
481 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 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 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 fn forecast_core(&self, history: &Array1<f64>, steps: usize) -> Array1<f64> {
542 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 let centered = if self.intercept != 0.0 {
553 &w_hist - self.intercept
554 } else {
555 w_hist.clone()
556 };
557
558 let (residuals, _sse) = Self::compute_residuals(
561 ¢ered,
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); w_forecasts.push(f_val + self.intercept);
603 }
604
605 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
625pub enum EstimationMethod {
626 #[default]
628 Css,
629 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
665const PARALLEL_BATCH_THRESHOLD: usize = 8;
667
668fn 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
678fn 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
694fn 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
709pub(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
779struct 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
801fn 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 if k == 0 {
827 return fallback();
828 }
829
830 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
865fn 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 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 (
900 Array1::zeros(order.p),
901 Array1::zeros(order.q),
902 Array1::zeros(order.P),
903 Array1::zeros(order.Q),
904 )
905}
906
907#[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
917pub 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
995pub fn auto_arima(data: &Array1<f64>, opts: AutoArimaOptions) -> Result<SarimaModel> {
997 validate_series(data)?;
998 let n = data.len();
999
1000 let d = estimate_d(data, opts.max_d, 0.05);
1002 let D = estimate_D(data, opts.m, opts.max_D);
1003
1004 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 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 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 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 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 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 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}