Skip to main content

so_tsa/
results.rs

1//! Unified result structures for time series analysis
2//!
3//! This module provides common result structures and diagnostics
4//! for time series models, including statistical summaries,
5//! residual diagnostics, and model comparison.
6
7use ndarray::Array1;
8use serde::{Deserialize, Serialize};
9use statrs::function::gamma;
10use std::collections::HashMap;
11
12/// Unified time series analysis results
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct TSAResults {
15    /// Model name/type
16    pub model_type: String,
17    /// Model parameters
18    pub params: HashMap<String, f64>,
19    /// Number of observations
20    pub n_obs: usize,
21    /// Number of parameters
22    pub n_params: usize,
23    /// Log-likelihood
24    pub log_likelihood: f64,
25    /// Akaike Information Criterion
26    pub aic: f64,
27    /// Bayesian Information Criterion
28    pub bic: f64,
29    /// Residuals
30    pub residuals: Array1<f64>,
31    /// Fitted values
32    pub fitted: Array1<f64>,
33    /// Model-specific diagnostics
34    pub diagnostics: HashMap<String, f64>,
35    /// Additional metadata
36    pub metadata: HashMap<String, String>,
37}
38
39impl TSAResults {
40    /// Create new TSA results
41    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        // Calculate information criteria
51        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    /// Add diagnostic statistic
70    pub fn add_diagnostic(&mut self, name: &str, value: f64) {
71        self.diagnostics.insert(name.to_string(), value);
72    }
73
74    /// Add metadata
75    pub fn add_metadata(&mut self, key: &str, value: &str) {
76        self.metadata.insert(key.to_string(), value.to_string());
77    }
78
79    /// Calculate residual statistics
80    pub fn residual_stats(&self) -> ResidualDiagnostics {
81        let residuals = &self.residuals;
82        let n = residuals.len();
83
84        // Basic statistics
85        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        // Jarque-Bera test for normality
105        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        // Ljung-Box test for autocorrelation
109        let lb_stat = ljung_box(&residuals, 10);
110        let lb_p_value = 1.0 - chi2_cdf(10, lb_stat);
111
112        // Durbin-Watson test for autocorrelation
113        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    /// Create summary string
130    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        // Parameter estimates
142        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        // Residual diagnostics
150        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        // Additional diagnostics
167        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    /// Compare with another model
178    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/// Residual diagnostics
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ResidualDiagnostics {
192    /// Mean of residuals
193    pub mean: f64,
194    /// Variance of residuals
195    pub variance: f64,
196    /// Standard deviation
197    pub std_dev: f64,
198    /// Skewness
199    pub skewness: f64,
200    /// Kurtosis
201    pub kurtosis: f64,
202    /// Jarque-Bera test (statistic, p-value)
203    pub jarque_bera: (f64, f64),
204    /// Ljung-Box test (statistic, p-value)
205    pub ljung_box: (f64, f64),
206    /// Durbin-Watson statistic
207    pub durbin_watson: f64,
208    /// Minimum residual
209    pub min: f64,
210    /// Maximum residual
211    pub max: f64,
212}
213
214impl ResidualDiagnostics {
215    /// Check if residuals are normally distributed (5% level)
216    pub fn is_normal(&self) -> bool {
217        self.jarque_bera.1 > 0.05
218    }
219
220    /// Check if residuals show autocorrelation (5% level)
221    pub fn has_autocorrelation(&self) -> bool {
222        self.ljung_box.1 < 0.05
223    }
224
225    /// Check Durbin-Watson for autocorrelation
226    /// DW ≈ 2: no autocorrelation
227    /// DW < 1.5: positive autocorrelation  
228    /// DW > 2.5: negative autocorrelation
229    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    /// Create summary
240    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/// Model comparison results
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct ModelComparison {
299    /// First model name
300    pub model_a: String,
301    /// Second model name
302    pub model_b: String,
303    /// AIC difference (B - A)
304    pub aic_diff: f64,
305    /// BIC difference (B - A)
306    pub bic_diff: f64,
307    /// Log-likelihood difference (B - A)
308    pub ll_diff: f64,
309}
310
311impl ModelComparison {
312    /// Determine which model is better based on AIC
313    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    /// Determine which model is better based on BIC
322    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    /// Strength of evidence based on AIC difference
331    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    /// Create comparison summary
347    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
383/// Chi-squared cumulative distribution function (simplified)
384pub fn chi2_cdf(k: usize, x: f64) -> f64 {
385    // Incomplete gamma function approximation
386    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    // Series expansion for incomplete gamma
394    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
407/// Ljung-Box test for autocorrelation
408fn 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
431/// Durbin-Watson test for autocorrelation
432fn durbin_watson(residuals: &Array1<f64>) -> f64 {
433    let n = residuals.len();
434    if n < 2 {
435        return 2.0; // No autocorrelation by definition
436    }
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}