1use ndarray::Array1;
8use serde::{Deserialize, Serialize};
9use statrs::function::gamma;
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TSAResults {
15 pub model_type: String,
17 pub params: HashMap<String, f64>,
19 pub n_obs: usize,
21 pub n_params: usize,
23 pub log_likelihood: f64,
25 pub aic: f64,
27 pub bic: f64,
29 pub residuals: Array1<f64>,
31 pub fitted: Array1<f64>,
33 pub diagnostics: HashMap<String, f64>,
35 pub metadata: HashMap<String, String>,
37}
38
39impl TSAResults {
40 pub fn new(
42 model_type: &str,
43 params: HashMap<String, f64>,
44 n_obs: usize,
45 n_params: usize,
46 log_likelihood: f64,
47 residuals: Array1<f64>,
48 fitted: Array1<f64>,
49 ) -> Self {
50 let aic = 2.0 * n_params as f64 - 2.0 * log_likelihood;
52 let bic = (n_obs as f64).ln() * n_params as f64 - 2.0 * log_likelihood;
53
54 Self {
55 model_type: model_type.to_string(),
56 params,
57 n_obs,
58 n_params,
59 log_likelihood,
60 aic,
61 bic,
62 residuals,
63 fitted,
64 diagnostics: HashMap::new(),
65 metadata: HashMap::new(),
66 }
67 }
68
69 pub fn add_diagnostic(&mut self, name: &str, value: f64) {
71 self.diagnostics.insert(name.to_string(), value);
72 }
73
74 pub fn add_metadata(&mut self, key: &str, value: &str) {
76 self.metadata.insert(key.to_string(), value.to_string());
77 }
78
79 pub fn residual_stats(&self) -> ResidualDiagnostics {
81 let residuals = &self.residuals;
82 let n = residuals.len();
83
84 let mean = residuals.mean().unwrap_or(0.0);
86 let variance = residuals.var(1.0);
87 let std_dev = variance.sqrt();
88 let skewness = if variance > 0.0 {
89 let mean_dev = residuals - mean;
90 let m3 = mean_dev.mapv(|x| x.powi(3)).mean().unwrap_or(0.0);
91 m3 / variance.powi(3).sqrt()
92 } else {
93 0.0
94 };
95
96 let kurtosis = if variance > 0.0 {
97 let mean_dev = residuals - mean;
98 let m4 = mean_dev.mapv(|x| x.powi(4)).mean().unwrap_or(0.0);
99 m4 / variance.powi(2)
100 } else {
101 0.0
102 };
103
104 let jb_stat = n as f64 / 6.0 * (skewness.powi(2) + 0.25 * (kurtosis - 3.0).powi(2));
106 let jb_p_value = 1.0 - chi2_cdf(2, jb_stat);
107
108 let lb_stat = ljung_box(&residuals, 10);
110 let lb_p_value = 1.0 - chi2_cdf(10, lb_stat);
111
112 let dw_stat = durbin_watson(&residuals);
114
115 ResidualDiagnostics {
116 mean,
117 variance,
118 std_dev,
119 skewness,
120 kurtosis,
121 jarque_bera: (jb_stat, jb_p_value),
122 ljung_box: (lb_stat, lb_p_value),
123 durbin_watson: dw_stat,
124 min: residuals.iter().fold(f64::INFINITY, |a, &b| a.min(b)),
125 max: residuals.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b)),
126 }
127 }
128
129 pub fn summary(&self) -> String {
131 let mut summary = String::new();
132 summary.push_str(&format!("{} Model Results\n", self.model_type));
133 summary.push_str(&format!("{}\n", "=".repeat(self.model_type.len() + 13)));
134
135 summary.push_str(&format!("Observations: {}\n", self.n_obs));
136 summary.push_str(&format!("Parameters: {}\n", self.n_params));
137 summary.push_str(&format!("Log-Likelihood: {:.4}\n", self.log_likelihood));
138 summary.push_str(&format!("AIC: {:.4}\n", self.aic));
139 summary.push_str(&format!("BIC: {:.4}\n", self.bic));
140
141 if !self.params.is_empty() {
143 summary.push_str("\nParameter Estimates:\n");
144 for (name, value) in &self.params {
145 summary.push_str(&format!(" {}: {:.6}\n", name, value));
146 }
147 }
148
149 let diag = self.residual_stats();
151 summary.push_str(&format!("\nResidual Diagnostics:\n"));
152 summary.push_str(&format!(" Mean: {:.6}\n", diag.mean));
153 summary.push_str(&format!(" Std Dev: {:.6}\n", diag.std_dev));
154 summary.push_str(&format!(" Skewness: {:.4}\n", diag.skewness));
155 summary.push_str(&format!(" Kurtosis: {:.4}\n", diag.kurtosis));
156 summary.push_str(&format!(
157 " Jarque-Bera: {:.4} (p={:.4})\n",
158 diag.jarque_bera.0, diag.jarque_bera.1
159 ));
160 summary.push_str(&format!(
161 " Ljung-Box(10): {:.4} (p={:.4})\n",
162 diag.ljung_box.0, diag.ljung_box.1
163 ));
164 summary.push_str(&format!(" Durbin-Watson: {:.4}\n", diag.durbin_watson));
165
166 if !self.diagnostics.is_empty() {
168 summary.push_str("\nModel Diagnostics:\n");
169 for (name, value) in &self.diagnostics {
170 summary.push_str(&format!(" {}: {:.6}\n", name, value));
171 }
172 }
173
174 summary
175 }
176
177 pub fn compare(&self, other: &Self) -> ModelComparison {
179 ModelComparison {
180 model_a: self.model_type.clone(),
181 model_b: other.model_type.clone(),
182 aic_diff: other.aic - self.aic,
183 bic_diff: other.bic - self.bic,
184 ll_diff: other.log_likelihood - self.log_likelihood,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ResidualDiagnostics {
192 pub mean: f64,
194 pub variance: f64,
196 pub std_dev: f64,
198 pub skewness: f64,
200 pub kurtosis: f64,
202 pub jarque_bera: (f64, f64),
204 pub ljung_box: (f64, f64),
206 pub durbin_watson: f64,
208 pub min: f64,
210 pub max: f64,
212}
213
214impl ResidualDiagnostics {
215 pub fn is_normal(&self) -> bool {
217 self.jarque_bera.1 > 0.05
218 }
219
220 pub fn has_autocorrelation(&self) -> bool {
222 self.ljung_box.1 < 0.05
223 }
224
225 pub fn durbin_watson_interpretation(&self) -> &'static str {
230 if self.durbin_watson < 1.5 {
231 "Positive autocorrelation"
232 } else if self.durbin_watson > 2.5 {
233 "Negative autocorrelation"
234 } else {
235 "No significant autocorrelation"
236 }
237 }
238
239 pub fn summary(&self) -> String {
241 let mut summary = String::new();
242 summary.push_str("Residual Diagnostics\n");
243 summary.push_str("====================\n");
244
245 summary.push_str(&format!(
246 "Normality (Jarque-Bera): {:.4} (p={:.4}) {}\n",
247 self.jarque_bera.0,
248 self.jarque_bera.1,
249 if self.is_normal() { "✓" } else { "✗" }
250 ));
251 summary.push_str(&format!(
252 "Autocorrelation (Ljung-Box): {:.4} (p={:.4}) {}\n",
253 self.ljung_box.0,
254 self.ljung_box.1,
255 if !self.has_autocorrelation() {
256 "✓"
257 } else {
258 "✗"
259 }
260 ));
261 summary.push_str(&format!(
262 "Durbin-Watson: {:.4} - {}\n",
263 self.durbin_watson,
264 self.durbin_watson_interpretation()
265 ));
266
267 summary.push_str(&format!("\nDistribution:\n"));
268 summary.push_str(&format!(" Mean: {:.6}\n", self.mean));
269 summary.push_str(&format!(" Std Dev: {:.6}\n", self.std_dev));
270 summary.push_str(&format!(
271 " Skewness: {:.4} ({})\n",
272 self.skewness,
273 if self.skewness.abs() > 0.5 {
274 "skewed"
275 } else {
276 "symmetric"
277 }
278 ));
279 summary.push_str(&format!(
280 " Kurtosis: {:.4} ({})\n",
281 self.kurtosis,
282 if self.kurtosis > 3.5 {
283 "leptokurtic"
284 } else if self.kurtosis < 2.5 {
285 "platykurtic"
286 } else {
287 "mesokurtic"
288 }
289 ));
290 summary.push_str(&format!(" Range: [{:.4}, {:.4}]\n", self.min, self.max));
291
292 summary
293 }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct ModelComparison {
299 pub model_a: String,
301 pub model_b: String,
303 pub aic_diff: f64,
305 pub bic_diff: f64,
307 pub ll_diff: f64,
309}
310
311impl ModelComparison {
312 pub fn aic_preferred(&self) -> String {
314 if self.aic_diff < 0.0 {
315 self.model_b.clone()
316 } else {
317 self.model_a.clone()
318 }
319 }
320
321 pub fn bic_preferred(&self) -> String {
323 if self.bic_diff < 0.0 {
324 self.model_b.clone()
325 } else {
326 self.model_a.clone()
327 }
328 }
329
330 pub fn aic_evidence(&self) -> &'static str {
332 let diff = self.aic_diff.abs();
333 if diff < 2.0 {
334 "No meaningful difference"
335 } else if diff < 4.0 {
336 "Weak evidence"
337 } else if diff < 7.0 {
338 "Positive evidence"
339 } else if diff < 10.0 {
340 "Strong evidence"
341 } else {
342 "Very strong evidence"
343 }
344 }
345
346 pub fn summary(&self) -> String {
348 let mut summary = String::new();
349 summary.push_str(&format!(
350 "Model Comparison: {} vs {}\n",
351 self.model_a, self.model_b
352 ));
353 summary.push_str(&format!(
354 "{}\n",
355 "=".repeat(self.model_a.len() + self.model_b.len() + 17)
356 ));
357
358 summary.push_str(&format!(
359 "AIC difference: {:.4} ({} preferred)\n",
360 self.aic_diff,
361 self.aic_preferred()
362 ));
363 summary.push_str(&format!(" Evidence: {}\n", self.aic_evidence()));
364
365 summary.push_str(&format!(
366 "BIC difference: {:.4} ({} preferred)\n",
367 self.bic_diff,
368 self.bic_preferred()
369 ));
370
371 summary.push_str(&format!("Log-likelihood difference: {:.4}\n", self.ll_diff));
372
373 if self.ll_diff > 0.0 {
374 summary.push_str(&format!(" {} has higher likelihood\n", self.model_b));
375 } else {
376 summary.push_str(&format!(" {} has higher likelihood\n", self.model_a));
377 }
378
379 summary
380 }
381}
382
383pub fn chi2_cdf(k: usize, x: f64) -> f64 {
385 if x <= 0.0 {
387 return 0.0;
388 }
389
390 let k_half = k as f64 / 2.0;
391 let x_half = x / 2.0;
392
393 let mut sum = 0.0;
395 let mut term = 1.0;
396 let mut n = 0;
397
398 while term > 1e-10 && n < 100 {
399 term = x_half.powi(n as i32) / (k_half + n as f64);
400 sum += term;
401 n += 1;
402 }
403
404 sum * (-x_half).exp() * x_half.powf(k_half) / gamma::gamma(k_half)
405}
406
407fn ljung_box(residuals: &Array1<f64>, lag: usize) -> f64 {
409 let n = residuals.len();
410 let mean = residuals.mean().unwrap_or(0.0);
411 let variance = residuals.var(1.0);
412
413 if variance <= 0.0 {
414 return 0.0;
415 }
416
417 let mut q = 0.0;
418
419 for k in 1..=lag {
420 let mut autocov = 0.0;
421 for t in k..n {
422 autocov += (residuals[t] - mean) * (residuals[t - k] - mean);
423 }
424 let rk = autocov / (variance * n as f64);
425 q += rk.powi(2) / (n - k) as f64;
426 }
427
428 q * n as f64 * (n as f64 + 2.0)
429}
430
431fn durbin_watson(residuals: &Array1<f64>) -> f64 {
433 let n = residuals.len();
434 if n < 2 {
435 return 2.0; }
437
438 let mut numerator = 0.0;
439 for i in 1..n {
440 numerator += (residuals[i] - residuals[i - 1]).powi(2);
441 }
442
443 let denominator: f64 = residuals.iter().map(|&x| x.powi(2)).sum();
444
445 if denominator > 0.0 {
446 numerator / denominator
447 } else {
448 2.0
449 }
450}