Skip to main content

copula_core/testing/
mod.rs

1//! Statistical testing utilities.
2//!
3//! This module provides goodness-of-fit procedures for evaluating copula
4//! models. At the moment, it contains goodness-of-fit statistics such as
5//! the Cramér-von Mises and Kolmogorov-Smirnov tests for comparing a
6//! fitted copula with empirical pseudo-observations.
7
8use crate::{utils::empirical_copula_cdf, Copula, CopulaError, Result};
9use nalgebra::DMatrix;
10
11/// Compute the Cramér-von Mises statistic for a copula model.
12///
13/// The statistic measures the squared difference between the model CDF and the
14/// empirical copula computed from the pseudo-observations:
15///
16/// `W = n \sum_i (C(u_i) - C_n(u_i))^2`,
17/// where `C` is the copula CDF, `C_n` is the empirical copula and `u_i` are the
18/// pseudo-observations.
19///
20/// # Arguments
21///
22/// * `copula` - Copula model implementing [`Copula`].
23/// * `pseudo_obs` - Matrix of pseudo-observations.
24///
25/// # Returns
26///
27/// The Cramér-von Mises statistic `W`.
28pub fn cramer_von_mises<C: Copula>(copula: &C, pseudo_obs: &DMatrix<f64>) -> Result<f64> {
29    crate::utils::validate_pseudo_observations(pseudo_obs)?;
30    if pseudo_obs.ncols() != copula.dimension() {
31        return Err(CopulaError::dimension_mismatch(
32            copula.dimension(),
33            pseudo_obs.ncols(),
34        ));
35    }
36
37    let n = pseudo_obs.nrows();
38    let mut sum = 0.0;
39    for i in 0..n {
40        let row = pseudo_obs.row(i);
41        let u: Vec<f64> = row.iter().copied().collect();
42        let c_n = empirical_copula_cdf(pseudo_obs, &u)?;
43        let c = copula.cdf(&u)?;
44        let diff = c - c_n;
45        sum += diff * diff;
46    }
47
48    Ok(n as f64 * sum)
49}
50
51/// Compute the Kolmogorov-Smirnov statistic for a copula model.
52///
53/// This statistic measures the maximum absolute difference between the model
54/// CDF and the empirical copula:
55///
56/// `D = \sqrt{n} \max_i |C(u_i) - C_n(u_i)|`,
57/// where `C` is the copula CDF, `C_n` is the empirical copula and `u_i` are the
58/// pseudo-observations.
59pub fn kolmogorov_smirnov<C: Copula>(copula: &C, pseudo_obs: &DMatrix<f64>) -> Result<f64> {
60    crate::utils::validate_pseudo_observations(pseudo_obs)?;
61    if pseudo_obs.ncols() != copula.dimension() {
62        return Err(CopulaError::dimension_mismatch(
63            copula.dimension(),
64            pseudo_obs.ncols(),
65        ));
66    }
67
68    let n = pseudo_obs.nrows();
69    let mut max_diff = 0.0_f64;
70    for i in 0..n {
71        let row = pseudo_obs.row(i);
72        let u: Vec<f64> = row.iter().copied().collect();
73        let c_n = empirical_copula_cdf(pseudo_obs, &u)?;
74        let c = copula.cdf(&u)?;
75        let diff = (c - c_n).abs();
76        if diff > max_diff {
77            max_diff = diff;
78        }
79    }
80
81    Ok((n as f64).sqrt() * max_diff)
82}
83
84/// Compute the Anderson-Darling statistic for a copula model.
85///
86/// This statistic compares the distribution of the model CDF values
87/// evaluated at the pseudo-observations against the uniform distribution.
88///
89/// `A^2 = -n - \frac{1}{n} \sum_{i=1}^n (2i-1)[\ln C_{(i)} + \ln(1-C_{(n+1-i)})]`
90/// where `C_{(i)}` are the ordered CDF values.
91pub fn anderson_darling<C: Copula>(copula: &C, pseudo_obs: &DMatrix<f64>) -> Result<f64> {
92    crate::utils::validate_pseudo_observations(pseudo_obs)?;
93    if pseudo_obs.ncols() != copula.dimension() {
94        return Err(CopulaError::dimension_mismatch(
95            copula.dimension(),
96            pseudo_obs.ncols(),
97        ));
98    }
99
100    let n = pseudo_obs.nrows();
101    let mut cdf_vals = Vec::with_capacity(n);
102    for i in 0..n {
103        let row = pseudo_obs.row(i);
104        let u: Vec<f64> = row.iter().copied().collect();
105        let c = copula.cdf(&u)?;
106        // avoid log(0)
107        let c = c.clamp(f64::MIN_POSITIVE, 1.0 - f64::EPSILON);
108        cdf_vals.push(c);
109    }
110    cdf_vals.sort_by(|a, b| a.total_cmp(b));
111
112    let mut sum = 0.0;
113    for (i, c) in cdf_vals.iter().enumerate() {
114        let j = i + 1;
115        let term1 = c.ln();
116        let term2 = (1.0 - cdf_vals[n - j]).ln();
117        sum += (2 * j - 1) as f64 * (term1 + term2);
118    }
119
120    Ok(-(n as f64) - sum / (n as f64))
121}
122
123/// Generate a distribution of Cramér-von Mises statistics using
124/// a simple multiplier bootstrap.
125///
126/// Random weights with mean 0 and variance 1 are drawn for each
127/// observation and used to perturb the empirical process. This
128/// approximates the sampling distribution of the statistic
129/// without resampling the data.
130pub fn cvm_multiplier_bootstrap<C, R>(
131    copula: &C,
132    pseudo_obs: &DMatrix<f64>,
133    n_rep: usize,
134    rng: &mut R,
135) -> Result<Vec<f64>>
136where
137    C: Copula,
138    R: rand::Rng + ?Sized,
139{
140    if n_rep == 0 {
141        return Err(CopulaError::invalid_parameter(
142            "n_rep must be at least 1",
143        ));
144    }
145    crate::utils::validate_pseudo_observations(pseudo_obs)?;
146    if pseudo_obs.ncols() != copula.dimension() {
147        return Err(CopulaError::dimension_mismatch(
148            copula.dimension(),
149            pseudo_obs.ncols(),
150        ));
151    }
152
153    let n = pseudo_obs.nrows();
154    let mut results = Vec::with_capacity(n_rep);
155    use rand_distr::{Distribution, StandardNormal};
156
157    for _ in 0..n_rep {
158        let mut weighted_sum = 0.0_f64;
159        for i in 0..n {
160            let row = pseudo_obs.row(i);
161            let u: Vec<f64> = row.iter().copied().collect();
162            let c_n = empirical_copula_cdf(pseudo_obs, &u)?;
163            let c = copula.cdf(&u)?;
164            let diff = c - c_n;
165            let w: f64 = StandardNormal.sample(rng);
166            weighted_sum += w * diff;
167        }
168        results.push(n as f64 * weighted_sum.powi(2));
169    }
170
171    Ok(results)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::archimedean::ClaytonCopula;
178    use rand::thread_rng;
179
180    #[test]
181    fn cvm_small_for_true_model() {
182        let mut rng = thread_rng();
183        let cop = ClaytonCopula::new(2.0).unwrap();
184        let data = cop.sample(100, &mut rng).unwrap();
185        let stat = cramer_von_mises(&cop, &data).unwrap();
186        assert!(stat.is_finite() && stat > 0.0);
187    }
188
189    #[test]
190    fn ks_statistic_finite() {
191        let mut rng = thread_rng();
192        let cop = ClaytonCopula::new(2.0).unwrap();
193        let data = cop.sample(50, &mut rng).unwrap();
194        let stat = kolmogorov_smirnov(&cop, &data).unwrap();
195        assert!(stat.is_finite() && stat > 0.0);
196    }
197
198    #[test]
199    fn ad_statistic_finite() {
200        let mut rng = thread_rng();
201        let cop = ClaytonCopula::new(2.0).unwrap();
202        let data = cop.sample(50, &mut rng).unwrap();
203        let stat = anderson_darling(&cop, &data).unwrap();
204        assert!(stat.is_finite() && stat > 0.0);
205    }
206
207    #[test]
208    fn multiplier_bootstrap_produces_samples() {
209        let mut rng = thread_rng();
210        let cop = ClaytonCopula::new(2.0).unwrap();
211        let data = cop.sample(40, &mut rng).unwrap();
212        let reps = cvm_multiplier_bootstrap(&cop, &data, 10, &mut rng).unwrap();
213        assert_eq!(reps.len(), 10);
214        assert!(reps.iter().all(|&x| x.is_finite()));
215    }
216}