Skip to main content

copula_core/archimedean/
gumbel.rs

1//! Gumbel copula implementation.
2//!
3//! ## Bibliography
4//! - Gumbel, E. J. (1960). Bivariate exponential distributions. *Journal of the
5//!   American Statistical Association*, 55(292), 698-707.
6//! - Nelsen, R. B. (2006). *An Introduction to Copulas*. Springer.
7//! - Joe, H. (2014). *Dependence Modeling with Copulas*. CRC Press.
8
9#[cfg(feature = "estimation")]
10use crate::traits::FittableCopula;
11#[cfg(feature = "estimation")]
12use crate::utils::kendall_tau;
13use crate::{ArchimedeanCopula, Copula, CopulaError, Result};
14use nalgebra::DMatrix;
15use rand::{Rng, RngExt};
16
17/// Gumbel copula with parameter `theta > 1`.
18#[derive(Debug, Clone)]
19pub struct GumbelCopula {
20    /// Copula parameter θ > 1
21    theta: f64,
22}
23
24validated_serde!("GumbelCopula", GumbelCopula { theta: f64 } => GumbelCopula::new(theta));
25
26impl GumbelCopula {
27    /// Create a new Gumbel copula with parameter `theta`.
28    pub fn new(theta: f64) -> Result<Self> {
29        if theta <= 1.0 || !theta.is_finite() {
30            return Err(CopulaError::invalid_parameter("theta must be > 1"));
31        }
32        Ok(Self { theta })
33    }
34}
35
36impl Copula for GumbelCopula {
37    fn cdf(&self, u: &[f64]) -> Result<f64> {
38        if u.len() != 2 {
39            return Err(CopulaError::dimension_mismatch(2, u.len()));
40        }
41        crate::error::validate_unit_range(u)?;
42
43        let sum = (-u[0].ln()).powf(self.theta) + (-u[1].ln()).powf(self.theta);
44        Ok(crate::utils::clamp_to_frechet_bounds(
45            u,
46            (-sum.powf(1.0 / self.theta)).exp(),
47        ))
48    }
49
50    fn pdf(&self, u: &[f64]) -> Result<f64> {
51        if u.len() != 2 {
52            return Err(CopulaError::dimension_mismatch(2, u.len()));
53        }
54        crate::error::validate_unit_range(u)?;
55
56        let theta = self.theta;
57        let ln_u = -u[0].ln();
58        let ln_v = -u[1].ln();
59
60        // A = (-ln u)^θ + (-ln v)^θ
61        let a = ln_u.powf(theta) + ln_v.powf(theta);
62
63        // C(u,v) from cdf
64        let c_uv = (-a.powf(1.0 / theta)).exp();
65
66        // PDF formula: c(u,v) = C(u,v) / (uv) × A^(-2 + 2/θ) × [(-ln u)(-ln v)]^(θ-1) × [θ - 1 + A^(1/θ)]
67        let term1 = c_uv / (u[0] * u[1]);
68        let term2 = a.powf(-2.0 + 2.0 / theta);
69        let term3 = (ln_u * ln_v).powf(theta - 1.0);
70        let term4 = theta - 1.0 + a.powf(1.0 / theta);
71
72        Ok(term1 * term2 * term3 * term4)
73    }
74
75    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
76        let mut samples = DMatrix::<f64>::zeros(n, 2);
77
78        for i in 0..n {
79            // Use conditional distribution method
80            let u1: f64 = rng.random::<f64>();
81            let v: f64 = rng.random::<f64>();
82
83            // For Gumbel copula, the conditional CDF is:
84            // C(u2|u1) = C(u1,u2) / u1 × exp(...) [complex formula]
85            // We use numerical inversion to find u2 given v
86
87            // Numerical root finding for u2 such that C(u2|u1) = v
88            let ln_u1 = -u1.ln();
89            let target = v;
90
91            // Binary search for u2
92            let mut u2_low: f64 = 1e-10;
93            let mut u2_high: f64 = 1.0 - 1e-10;
94            let mut u2: f64 = 0.5;
95
96            for _ in 0..50 {
97                // max iterations
98                u2 = (u2_low + u2_high) / 2.0;
99                let ln_u2 = -u2.ln();
100                let a = ln_u1.powf(self.theta) + ln_u2.powf(self.theta);
101                let a_root = a.powf(1.0 / self.theta);
102
103                // Conditional CDF: ∂C/∂u1 = C(u1,u2) × (1/u1) × a_root^(-1) × ln_u1^(θ-1) × a^((1-θ)/θ)
104                let c_uv = (-a_root).exp();
105                let cond_cdf = c_uv
106                    * a_root.powf(-1.0)
107                    * ln_u1.powf(self.theta - 1.0)
108                    * a.powf((1.0 - self.theta) / self.theta)
109                    / u1;
110
111                if (cond_cdf - target).abs() < 1e-10 {
112                    break;
113                }
114
115                if cond_cdf < target {
116                    u2_low = u2;
117                } else {
118                    u2_high = u2;
119                }
120            }
121
122            samples[(i, 0)] = u1;
123            samples[(i, 1)] = u2;
124        }
125
126        Ok(samples)
127    }
128
129    fn dimension(&self) -> usize {
130        2
131    }
132}
133
134impl ArchimedeanCopula for GumbelCopula {
135    fn phi(&self, t: f64) -> Result<f64> {
136        if t <= 0.0 || t > 1.0 {
137            return Err(CopulaError::invalid_range(vec![t]));
138        }
139        Ok((-t.ln()).powf(self.theta))
140    }
141
142    fn phi_inv(&self, s: f64) -> Result<f64> {
143        if s < 0.0 {
144            return Err(CopulaError::invalid_range(vec![s]));
145        }
146        Ok((-s.powf(1.0 / self.theta)).exp())
147    }
148
149    fn phi_inv_deriv(&self, s: f64, k: usize) -> Result<f64> {
150        if s < 0.0 {
151            return Err(CopulaError::invalid_range(vec![s]));
152        }
153        match k {
154            1 => {
155                let base = s.powf(1.0 / self.theta - 1.0);
156                Ok(-self.phi_inv(s)? * base / self.theta)
157            }
158            2 => {
159                let phi_inv = self.phi_inv(s)?;
160                let term1 = (1.0 / self.theta.powi(2)) * s.powf(2.0 / self.theta - 2.0);
161                let term2 =
162                    (1.0 / self.theta) * (1.0 / self.theta - 1.0) * s.powf(1.0 / self.theta - 2.0);
163                Ok(phi_inv * (term1 - term2))
164            }
165            _ => Err(CopulaError::not_implemented("phi_inv_deriv k>2")),
166        }
167    }
168}
169
170#[cfg(feature = "estimation")]
171impl FittableCopula for GumbelCopula {
172    type Parameters = f64;
173
174    fn fit(&mut self, pseudo_obs: &DMatrix<f64>) -> Result<Self::Parameters> {
175        self.fit_moments(pseudo_obs)
176    }
177
178    fn log_likelihood(&self, pseudo_obs: &DMatrix<f64>) -> Result<f64> {
179        if pseudo_obs.ncols() != 2 {
180            return Err(CopulaError::dimension_mismatch(2, pseudo_obs.ncols()));
181        }
182
183        let mut log_lik = 0.0;
184        for i in 0..pseudo_obs.nrows() {
185            let u = [pseudo_obs[(i, 0)], pseudo_obs[(i, 1)]];
186            let pdf_val = self.pdf(&u)?;
187            if pdf_val <= 0.0 {
188                return Err(CopulaError::numerical(
189                    "PDF value must be positive for log-likelihood",
190                ));
191            }
192            log_lik += pdf_val.ln();
193        }
194
195        Ok(log_lik)
196    }
197
198    fn fit_moments(&mut self, pseudo_obs: &DMatrix<f64>) -> Result<Self::Parameters> {
199        if pseudo_obs.ncols() != 2 {
200            return Err(CopulaError::dimension_mismatch(2, pseudo_obs.ncols()));
201        }
202        for j in 0..2 {
203            crate::error::validate_unit_range(pseudo_obs.column(j).as_slice())?;
204        }
205        let u: Vec<f64> = pseudo_obs.column(0).iter().copied().collect();
206        let v: Vec<f64> = pseudo_obs.column(1).iter().copied().collect();
207        let tau = kendall_tau(&u, &v)?;
208        if tau >= 1.0 {
209            return Err(CopulaError::invalid_parameter("tau must be < 1"));
210        }
211        let theta = 1.0 / (1.0 - tau);
212        if theta <= 1.0 {
213            return Err(CopulaError::invalid_parameter("theta must be > 1"));
214        }
215        self.theta = theta;
216        Ok(theta)
217    }
218
219    fn parameters(&self) -> Self::Parameters {
220        self.theta
221    }
222
223    fn set_parameters(&mut self, params: Self::Parameters) -> Result<()> {
224        *self = Self::new(params)?;
225        Ok(())
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn cdf_matches_formula() {
235        let cop = GumbelCopula::new(1.5).unwrap();
236        let cdf = cop.cdf(&[0.7, 0.8]).unwrap();
237        let sum = (-0.7_f64.ln()).powf(1.5) + (-0.8_f64.ln()).powf(1.5);
238        let expected = (-sum.powf(1.0 / 1.5)).exp();
239        assert!((cdf - expected).abs() < 1e-10);
240    }
241
242    #[test]
243    fn phi_inverse_derivatives() {
244        let cop = GumbelCopula::new(2.0).unwrap();
245        let s = 0.3;
246        let phi_inv = cop.phi_inv(s).unwrap();
247        let h = 1e-6;
248        let fd = (cop.phi_inv(s + h).unwrap() - phi_inv) / h;
249        let analytic = cop.phi_inv_deriv(s, 1).unwrap();
250        assert!((fd - analytic).abs() < 1e-4);
251    }
252
253    #[test]
254    fn new_rejects_invalid_theta() {
255        assert!(GumbelCopula::new(1.0).is_err());
256        assert!(GumbelCopula::new(f64::NAN).is_err());
257    }
258
259    #[test]
260    fn cdf_validates_input() {
261        let cop = GumbelCopula::new(2.0).unwrap();
262        // Wrong dimension
263        assert!(cop.cdf(&[0.5]).is_err());
264        // Values outside [0,1]
265        assert!(cop.cdf(&[1.2, 0.3]).is_err());
266    }
267}