copula_core/archimedean/
clayton.rs1use crate::traits::BoundedParameters;
11#[cfg(feature = "estimation")]
12use crate::traits::FittableCopula;
13#[cfg(feature = "estimation")]
14use crate::utils::kendall_tau;
15use crate::{ArchimedeanCopula, Copula, CopulaError, Result};
16use nalgebra::DMatrix;
17use rand::Rng;
18#[cfg(feature = "estimation")]
19use statrs::distribution::ContinuousCDF;
20
21#[derive(Debug, Clone)]
23pub struct ClaytonCopula {
24 theta: f64,
26}
27
28validated_serde!("ClaytonCopula", ClaytonCopula { theta: f64 } => ClaytonCopula::new(theta));
29
30impl ClaytonCopula {
31 pub fn new(theta: f64) -> Result<Self> {
33 if theta <= 0.0 || !theta.is_finite() {
34 return Err(CopulaError::invalid_parameter("theta must be positive"));
35 }
36 Ok(Self { theta })
37 }
38}
39
40impl BoundedParameters for ClaytonCopula {
41 fn parameter_bounds() -> Vec<(f64, f64)> {
42 vec![(1e-4, 10.0)]
43 }
44
45 fn check_bounds(&self) -> Result<()> {
46 let (min, max) = Self::parameter_bounds()[0];
47 if self.theta < min || self.theta > max {
48 Err(CopulaError::invalid_parameter("theta out of bounds"))
49 } else {
50 Ok(())
51 }
52 }
53}
54
55impl Copula for ClaytonCopula {
56 fn cdf(&self, u: &[f64]) -> Result<f64> {
57 if u.len() != 2 {
58 return Err(CopulaError::dimension_mismatch(2, u.len()));
59 }
60 crate::error::validate_unit_range(u)?;
61 let sum = u[0].powf(-self.theta) + u[1].powf(-self.theta) - 1.0;
62 if sum <= 0.0 {
63 Ok(0.0)
64 } else {
65 Ok(crate::utils::clamp_to_frechet_bounds(
66 u,
67 sum.powf(-1.0 / self.theta),
68 ))
69 }
70 }
71
72 fn pdf(&self, u: &[f64]) -> Result<f64> {
73 if u.len() != 2 {
74 return Err(CopulaError::dimension_mismatch(2, u.len()));
75 }
76 crate::error::validate_unit_range(u)?;
77 let (u1, u2) = (u[0], u[1]);
78 let theta = self.theta;
79 let sum = u1.powf(-theta) + u2.powf(-theta) - 1.0;
80 if sum <= 0.0 {
81 return Ok(0.0);
82 }
83 let base = sum.powf(-2.0 - 1.0 / theta);
84 let density = (1.0 + theta) * u1.powf(-1.0 - theta) * u2.powf(-1.0 - theta) * base;
85 Ok(density)
86 }
87
88 fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
89 use rand_distr::{Distribution, Exp1, Gamma};
90
91 let gamma = Gamma::<f64>::new(1.0 / self.theta, 1.0)
92 .map_err(|e| CopulaError::invalid_parameter(format!("gamma distribution: {}", e)))?;
93 let mut samples = DMatrix::<f64>::zeros(n, 2);
94 for i in 0..n {
95 let w: f64 = gamma.sample(rng);
96 for j in 0..2 {
97 let e: f64 = Exp1.sample(rng);
98 samples[(i, j)] = f64::powf(1.0 + e / w, -1.0 / self.theta);
99 }
100 }
101 Ok(samples)
102 }
103
104 fn dimension(&self) -> usize {
105 2
106 }
107
108 fn tail_dependence(&self) -> Result<(f64, f64)> {
109 let lambda_l = 2f64.powf(-1.0 / self.theta);
110 Ok((lambda_l, 0.0))
111 }
112}
113
114impl ArchimedeanCopula for ClaytonCopula {
115 fn phi(&self, t: f64) -> Result<f64> {
116 if t <= 0.0 || t > 1.0 {
117 return Err(CopulaError::invalid_range(vec![t]));
118 }
119 Ok((t.powf(-self.theta) - 1.0) / self.theta)
120 }
121
122 fn phi_inv(&self, s: f64) -> Result<f64> {
123 if s < 0.0 {
124 return Err(CopulaError::invalid_range(vec![s]));
125 }
126 Ok((1.0 + self.theta * s).powf(-1.0 / self.theta))
127 }
128
129 fn phi_inv_deriv(&self, s: f64, k: usize) -> Result<f64> {
130 if s < 0.0 {
131 return Err(CopulaError::invalid_range(vec![s]));
132 }
133 let base = 1.0 + self.theta * s;
134 let pow = -1.0 / self.theta;
135 match k {
136 1 => Ok(-base.powf(pow - 1.0)),
137 2 => Ok((1.0 + self.theta) * base.powf(pow - 2.0)),
138 _ => Err(CopulaError::not_implemented("phi_inv_deriv k>2")),
139 }
140 }
141}
142
143#[cfg(feature = "estimation")]
144impl FittableCopula for ClaytonCopula {
145 type Parameters = f64;
146
147 fn fit(&mut self, pseudo_obs: &DMatrix<f64>) -> Result<Self::Parameters> {
148 use argmin::core::{CostFunction, Error as ArgminError, Executor, State};
149 use argmin::solver::brent::BrentOpt;
150
151 if pseudo_obs.ncols() != 2 {
152 return Err(CopulaError::dimension_mismatch(2, pseudo_obs.ncols()));
153 }
154 crate::utils::validate_pseudo_observations(pseudo_obs)?;
155
156 struct Nll<'a> {
157 data: &'a DMatrix<f64>,
158 }
159
160 impl<'a> CostFunction for Nll<'a> {
161 type Param = f64;
162 type Output = f64;
163
164 fn cost(&self, theta: &Self::Param) -> std::result::Result<f64, ArgminError> {
165 if *theta <= 0.0 {
166 return Ok(f64::INFINITY);
167 }
168 let mut nll = 0.0;
169 for i in 0..self.data.nrows() {
170 let u1 = self.data[(i, 0)];
171 let u2 = self.data[(i, 1)];
172 let sum = u1.powf(-*theta) + u2.powf(-*theta) - 1.0;
173 if sum <= 0.0 {
174 return Ok(f64::INFINITY);
175 }
176 let lp = (1.0 + theta).ln()
177 + (-1.0 - theta) * (u1.ln() + u2.ln())
178 + (-2.0 - 1.0 / theta) * sum.ln();
179 nll -= lp;
180 }
181 Ok(nll)
182 }
183 }
184
185 let op = Nll { data: pseudo_obs };
186 let (min, max) = Self::parameter_bounds()[0];
187 let solver = BrentOpt::new(min, max);
188 let res = Executor::new(op, solver)
189 .configure(|state| state.max_iters(100))
190 .run()
191 .map_err(|e| CopulaError::optimization(e.to_string()))?;
192 let theta = *res
193 .state()
194 .get_best_param()
195 .ok_or_else(|| CopulaError::optimization("optimizer returned no best parameter"))?;
196 self.theta = theta;
197 Ok(theta)
198 }
199
200 fn log_likelihood(&self, pseudo_obs: &DMatrix<f64>) -> Result<f64> {
201 if pseudo_obs.ncols() != 2 {
202 return Err(CopulaError::dimension_mismatch(2, pseudo_obs.ncols()));
203 }
204 crate::utils::validate_pseudo_observations(pseudo_obs)?;
205 let mut ll = 0.0;
206 for i in 0..pseudo_obs.nrows() {
207 let u1 = pseudo_obs[(i, 0)];
208 let u2 = pseudo_obs[(i, 1)];
209 let sum = u1.powf(-self.theta) + u2.powf(-self.theta) - 1.0;
210 if sum <= 0.0 {
211 return Err(CopulaError::numerical("log_likelihood invalid sum"));
212 }
213 ll += (1.0 + self.theta).ln()
214 + (-1.0 - self.theta) * (u1.ln() + u2.ln())
215 + (-2.0 - 1.0 / self.theta) * sum.ln();
216 }
217 Ok(ll)
218 }
219
220 fn fit_moments(&mut self, pseudo_obs: &DMatrix<f64>) -> Result<Self::Parameters> {
221 if pseudo_obs.ncols() != 2 {
222 return Err(CopulaError::dimension_mismatch(2, pseudo_obs.ncols()));
223 }
224 for j in 0..2 {
225 crate::error::validate_unit_range(pseudo_obs.column(j).as_slice())?;
226 }
227 let u: Vec<f64> = pseudo_obs.column(0).iter().copied().collect();
228 let v: Vec<f64> = pseudo_obs.column(1).iter().copied().collect();
229 let tau = kendall_tau(&u, &v)?;
230 if (1.0 - tau).abs() < f64::EPSILON {
231 return Err(CopulaError::invalid_parameter("tau must be < 1"));
232 }
233 let theta = 2.0 * tau / (1.0 - tau);
234 self.theta = theta;
235 Ok(theta)
236 }
237
238 fn parameters(&self) -> Self::Parameters {
239 self.theta
240 }
241
242 fn set_parameters(&mut self, params: Self::Parameters) -> Result<()> {
243 *self = Self::new(params)?;
244 Ok(())
245 }
246
247 fn standard_errors(&self, pseudo_obs: &DMatrix<f64>) -> Result<Self::Parameters> {
248 if pseudo_obs.ncols() != 2 {
249 return Err(CopulaError::dimension_mismatch(2, pseudo_obs.ncols()));
250 }
251 crate::utils::validate_pseudo_observations(pseudo_obs)?;
252
253 let h = 1e-5;
254 let theta = self.theta;
255 let ll = |t: f64| {
256 let cop = Self { theta: t };
257 cop.log_likelihood(pseudo_obs)
258 };
259 let ll_p = ll(theta + h)?;
260 let ll_m = ll(theta - h)?;
261 let ll_0 = ll(theta)?;
262 let second = (ll_p - 2.0 * ll_0 + ll_m) / (h * h);
263 if second >= 0.0 || !second.is_finite() {
264 return Err(CopulaError::numerical("invalid hessian"));
265 }
266 let var = -1.0 / second;
267 Ok(var.sqrt())
268 }
269
270 fn confidence_intervals(
271 &self,
272 pseudo_obs: &DMatrix<f64>,
273 confidence_level: f64,
274 ) -> Result<(Self::Parameters, Self::Parameters)> {
275 if !(0.0 < confidence_level && confidence_level < 1.0) {
276 return Err(CopulaError::invalid_parameter("confidence_level"));
277 }
278 let se = self.standard_errors(pseudo_obs)?;
279 let z = statrs::distribution::Normal::new(0.0, 1.0)
280 .map_err(|_| CopulaError::computation("failed to create standard normal distribution"))?
281 .inverse_cdf(0.5 + confidence_level / 2.0);
282 let lower = self.theta - z * se;
283 let upper = self.theta + z * se;
284 Ok((lower, upper))
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn cdf_matches_formula() {
294 let cop = ClaytonCopula::new(2.0).unwrap();
295 let cdf = cop.cdf(&[0.5, 0.5]).unwrap();
296 let expected = (0.5_f64.powf(-2.0) + 0.5_f64.powf(-2.0) - 1.0).powf(-0.5);
298 assert!((cdf - expected).abs() < 1e-10);
299 }
300
301 #[test]
302 fn phi_inverse_derivatives() {
303 let cop = ClaytonCopula::new(1.5).unwrap();
304 let s = 0.2;
305 let phi_inv = cop.phi_inv(s).unwrap();
306 let h = 1e-6;
308 let fd = (cop.phi_inv(s + h).unwrap() - phi_inv) / h;
309 let analytic = cop.phi_inv_deriv(s, 1).unwrap();
310 assert!((fd - analytic).abs() < 1e-4);
311 }
312
313 #[test]
314 fn new_rejects_invalid_theta() {
315 assert!(ClaytonCopula::new(0.0).is_err());
316 assert!(ClaytonCopula::new(-1.0).is_err());
317 }
318
319 #[test]
320 fn cdf_validates_input() {
321 let cop = ClaytonCopula::new(1.5).unwrap();
322 assert!(cop.cdf(&[0.5]).is_err());
323 assert!(cop.cdf(&[0.5, 1.2]).is_err());
324 }
325
326 #[test]
327 fn pdf_matches_formula() {
328 let cop = ClaytonCopula::new(2.0).unwrap();
329 let pdf = cop.pdf(&[0.4, 0.6]).unwrap();
330 let theta = 2.0;
331 let sum = 0.4_f64.powf(-theta) + 0.6_f64.powf(-theta) - 1.0;
332 let expected = (1.0 + theta)
333 * 0.4_f64.powf(-1.0 - theta)
334 * 0.6_f64.powf(-1.0 - theta)
335 * sum.powf(-2.0 - 1.0 / theta);
336 assert!((pdf - expected).abs() < 1e-10);
337 }
338
339 #[test]
340 fn sample_produces_valid_data() {
341 let mut rng = rand::rng();
342 let cop = ClaytonCopula::new(1.2).unwrap();
343 let samples = cop.sample(10, &mut rng).unwrap();
344 assert_eq!(samples.ncols(), 2);
345 assert_eq!(samples.nrows(), 10);
346 for i in 0..10 {
347 for j in 0..2 {
348 assert!(samples[(i, j)] > 0.0 && samples[(i, j)] < 1.0);
349 }
350 }
351 }
352
353 #[test]
354 fn tail_dependence_coefficients() {
355 let cop = ClaytonCopula::new(2.0).unwrap();
356 let (lower, upper) = cop.tail_dependence().unwrap();
357 assert!((lower - 2f64.powf(-0.5)).abs() < 1e-12);
358 assert_eq!(upper, 0.0);
359 }
360
361 #[test]
362 fn parameter_bounds_are_enforced() {
363 let bad = ClaytonCopula { theta: -1.0 };
364 assert!(bad.check_bounds().is_err());
365 let good = ClaytonCopula::new(2.0).unwrap();
366 assert!(good.check_bounds().is_ok());
367 }
368}