Skip to main content

scirs2_stats/
copulas.rs

1//! Copula Models for Dependence Structures
2//!
3//! This module provides copula functions for modeling multivariate dependence
4//! structures independently from marginal distributions:
5//!
6//! - **Gaussian copula**: normal dependence, no tail dependence
7//! - **Student-t copula**: symmetric tail dependence
8//! - **Clayton copula**: lower tail dependence (Archimedean)
9//! - **Gumbel copula**: upper tail dependence (Archimedean)
10//! - **Frank copula**: symmetric, no tail dependence (Archimedean)
11//! - **Fitting**: maximum pseudo-likelihood estimation
12//! - **Tail dependence**: upper and lower tail dependence coefficients
13//!
14//! # References
15//!
16//! - Sklar, A. (1959). Fonctions de repartition a n dimensions et leurs marges.
17//! - Nelsen, R.B. (2006). An Introduction to Copulas. Springer.
18//! - Joe, H. (2014). Dependence Modeling with Copulas. Chapman & Hall/CRC.
19//! - Genest, C. & Favre, A.-C. (2007). Everything You Always Wanted to Know
20//!   About Copula Modeling but Were Afraid to Ask. J. Hydrol. Eng.
21
22use crate::error::{StatsError, StatsResult};
23use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
24use std::f64::consts::PI;
25
26// ---------------------------------------------------------------------------
27// Copula trait
28// ---------------------------------------------------------------------------
29
30/// A bivariate copula C(u, v) where u, v are in [0, 1].
31pub trait Copula: std::fmt::Debug {
32    /// Evaluate the copula CDF: C(u, v)
33    fn cdf(&self, u: f64, v: f64) -> f64;
34
35    /// Evaluate the copula density: c(u, v) = d^2 C / du dv
36    fn pdf(&self, u: f64, v: f64) -> f64;
37
38    /// Log-density: ln(c(u, v))
39    fn log_pdf(&self, u: f64, v: f64) -> f64 {
40        let d = self.pdf(u, v);
41        if d > 0.0 {
42            d.ln()
43        } else {
44            f64::NEG_INFINITY
45        }
46    }
47
48    /// Conditional CDF: C(v | u) = dC/du
49    fn conditional_v_given_u(&self, u: f64, v: f64) -> f64;
50
51    /// Conditional CDF: C(u | v) = dC/dv
52    fn conditional_u_given_v(&self, u: f64, v: f64) -> f64;
53
54    /// Upper tail dependence coefficient lambda_U
55    fn upper_tail_dependence(&self) -> f64;
56
57    /// Lower tail dependence coefficient lambda_L
58    fn lower_tail_dependence(&self) -> f64;
59
60    /// Kendall's tau (rank correlation) implied by the copula
61    fn kendalls_tau(&self) -> f64;
62
63    /// Name of the copula family
64    fn family_name(&self) -> &str;
65
66    /// Number of parameters
67    fn n_params(&self) -> usize;
68
69    /// Get parameter vector
70    fn params(&self) -> Vec<f64>;
71}
72
73// ---------------------------------------------------------------------------
74// Helper: standard normal CDF and inverse
75// ---------------------------------------------------------------------------
76
77/// Standard normal CDF (Phi)
78fn norm_cdf(x: f64) -> f64 {
79    0.5 * (1.0 + erf_approx(x / std::f64::consts::SQRT_2))
80}
81
82/// Abramowitz & Stegun erf approximation
83fn erf_approx(x: f64) -> f64 {
84    let sign = if x >= 0.0 { 1.0 } else { -1.0 };
85    let x = x.abs();
86    let t = 1.0 / (1.0 + 0.3275911 * x);
87    let poly = t
88        * (0.254_829_592
89            + t * (-0.284_496_736
90                + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
91    sign * (1.0 - poly * (-x * x).exp())
92}
93
94/// Standard normal quantile function (inverse CDF) via rational approximation
95/// Beasley-Springer-Moro algorithm
96fn norm_ppf(p: f64) -> f64 {
97    if p <= 0.0 {
98        return f64::NEG_INFINITY;
99    }
100    if p >= 1.0 {
101        return f64::INFINITY;
102    }
103    if (p - 0.5).abs() < 1e-15 {
104        return 0.0;
105    }
106    // Peter Acklam's algorithm (accurate to ~1.15e-9)
107    let a = [
108        -3.969_683_028_665_376e1,
109        2.209_460_984_245_205e2,
110        -2.759_285_104_469_687e2,
111        1.383_577_518_672_690e2,
112        -3.066_479_806_614_716e1,
113        2.506_628_277_459_239,
114    ];
115    let b = [
116        -5.447_609_879_822_406e1,
117        1.615_858_368_580_409e2,
118        -1.556_989_798_598_866e2,
119        6.680_131_188_771_972e1,
120        -1.328_068_155_288_572e1,
121    ];
122    let c = [
123        -7.784_894_002_430_293e-3,
124        -3.223_964_580_411_365e-1,
125        -2.400_758_277_161_838,
126        -2.549_732_539_343_734,
127        4.374_664_141_464_968,
128        2.938_163_982_698_783,
129    ];
130    let d = [
131        7.784_695_709_041_462e-3,
132        3.224_671_290_700_398e-1,
133        2.445_134_137_142_996,
134        3.754_408_661_907_416,
135    ];
136
137    let p_low = 0.02425;
138    let p_high = 1.0 - p_low;
139
140    if p < p_low {
141        let q = (-2.0 * p.ln()).sqrt();
142        (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
143            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
144    } else if p <= p_high {
145        let q = p - 0.5;
146        let r = q * q;
147        (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
148            / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0)
149    } else {
150        let q = (-2.0 * (1.0 - p).ln()).sqrt();
151        -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
152            / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
153    }
154}
155
156/// Standard normal PDF
157fn norm_pdf(x: f64) -> f64 {
158    let inv_sqrt_2pi = 1.0 / (2.0 * PI).sqrt();
159    inv_sqrt_2pi * (-0.5 * x * x).exp()
160}
161
162/// Student-t CDF approximation (for small integer or real df)
163fn student_t_cdf(x: f64, df: f64) -> f64 {
164    if df <= 0.0 {
165        return 0.5;
166    }
167    // Use incomplete beta function relation
168    // P(T <= x) = 1 - 0.5 * I(df/(df+x^2), df/2, 1/2) for x >= 0
169    let t = df / (df + x * x);
170    let ib = regularized_incomplete_beta(t, df / 2.0, 0.5);
171    if x >= 0.0 {
172        1.0 - 0.5 * ib
173    } else {
174        0.5 * ib
175    }
176}
177
178/// Regularized incomplete beta function I_x(a, b) via continued fraction
179fn regularized_incomplete_beta(x: f64, a: f64, b: f64) -> f64 {
180    if x <= 0.0 {
181        return 0.0;
182    }
183    if x >= 1.0 {
184        return 1.0;
185    }
186    // Use symmetry if needed
187    if x > (a + 1.0) / (a + b + 2.0) {
188        return 1.0 - regularized_incomplete_beta(1.0 - x, b, a);
189    }
190    // Lentz's continued fraction
191    let lnbeta = ln_beta(a, b);
192    let front = (x.ln() * a + (1.0 - x).ln() * b - lnbeta).exp() / a;
193    let mut f = 1.0;
194    let mut c = 1.0;
195    let mut d = 1.0 - (a + b) * x / (a + 1.0);
196    if d.abs() < 1e-30 {
197        d = 1e-30;
198    }
199    d = 1.0 / d;
200    f = d;
201    for m in 1..200 {
202        let mf = m as f64;
203        // Even step
204        let num_even = mf * (b - mf) * x / ((a + 2.0 * mf - 1.0) * (a + 2.0 * mf));
205        d = 1.0 + num_even * d;
206        if d.abs() < 1e-30 {
207            d = 1e-30;
208        }
209        c = 1.0 + num_even / c;
210        if c.abs() < 1e-30 {
211            c = 1e-30;
212        }
213        d = 1.0 / d;
214        f *= d * c;
215        // Odd step
216        let num_odd = -(a + mf) * (a + b + mf) * x / ((a + 2.0 * mf) * (a + 2.0 * mf + 1.0));
217        d = 1.0 + num_odd * d;
218        if d.abs() < 1e-30 {
219            d = 1e-30;
220        }
221        c = 1.0 + num_odd / c;
222        if c.abs() < 1e-30 {
223            c = 1e-30;
224        }
225        d = 1.0 / d;
226        let delta = d * c;
227        f *= delta;
228        if (delta - 1.0).abs() < 1e-12 {
229            break;
230        }
231    }
232    front * f
233}
234
235/// ln(Beta(a, b)) = ln(Gamma(a)) + ln(Gamma(b)) - ln(Gamma(a+b))
236fn ln_beta(a: f64, b: f64) -> f64 {
237    ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b)
238}
239
240/// Stirling/Lanczos approximation of ln(Gamma(x))
241fn ln_gamma(x: f64) -> f64 {
242    if x <= 0.0 {
243        return f64::INFINITY;
244    }
245    // Lanczos approximation (g=7, n=9)
246    let coef = [
247        0.999_999_999_999_809_93,
248        676.520_368_121_885_1,
249        -1_259.139_216_722_403,
250        771.323_428_777_653_1,
251        -176.615_029_162_140_6,
252        12.507_343_278_686_905,
253        -0.138_571_095_265_720_12,
254        9.984_369_578_019_572e-6,
255        1.505_632_735_149_311_6e-7,
256    ];
257    let g = 7.0;
258    let xx = x - 1.0;
259    let mut sum = coef[0];
260    for i in 1..9 {
261        sum += coef[i] / (xx + i as f64);
262    }
263    let t = xx + g + 0.5;
264    0.5 * (2.0 * PI).ln() + (xx + 0.5) * t.ln() - t + sum.ln()
265}
266
267/// Student-t quantile (inverse CDF) via Newton's method
268fn student_t_ppf(p: f64, df: f64) -> f64 {
269    if p <= 0.0 {
270        return f64::NEG_INFINITY;
271    }
272    if p >= 1.0 {
273        return f64::INFINITY;
274    }
275    // Start from normal approximation
276    let mut x = norm_ppf(p);
277    // Newton iterations
278    for _ in 0..30 {
279        let cdf = student_t_cdf(x, df);
280        let pdf = student_t_pdf(x, df);
281        if pdf.abs() < 1e-30 {
282            break;
283        }
284        let delta = (cdf - p) / pdf;
285        x -= delta;
286        if delta.abs() < 1e-12 {
287            break;
288        }
289    }
290    x
291}
292
293/// Student-t PDF
294fn student_t_pdf(x: f64, df: f64) -> f64 {
295    let half_df = df / 2.0;
296    let coef = (ln_gamma(half_df + 0.5) - ln_gamma(half_df) - 0.5 * (df * PI).ln()).exp();
297    coef * (1.0 + x * x / df).powf(-(df + 1.0) / 2.0)
298}
299
300// ---------------------------------------------------------------------------
301// Gaussian copula
302// ---------------------------------------------------------------------------
303
304/// Gaussian (normal) copula parameterized by correlation rho in (-1, 1).
305#[derive(Debug, Clone)]
306pub struct GaussianCopula {
307    /// Correlation parameter
308    pub rho: f64,
309}
310
311impl GaussianCopula {
312    /// Create a Gaussian copula with the given correlation.
313    pub fn new(rho: f64) -> StatsResult<Self> {
314        if rho <= -1.0 || rho >= 1.0 {
315            return Err(StatsError::InvalidArgument(format!(
316                "rho must be in (-1, 1), got {}",
317                rho
318            )));
319        }
320        Ok(Self { rho })
321    }
322}
323
324impl Copula for GaussianCopula {
325    fn cdf(&self, u: f64, v: f64) -> f64 {
326        let u = u.max(1e-10).min(1.0 - 1e-10);
327        let v = v.max(1e-10).min(1.0 - 1e-10);
328        let x = norm_ppf(u);
329        let y = norm_ppf(v);
330        // Bivariate normal CDF approximation
331        bivariate_normal_cdf(x, y, self.rho)
332    }
333
334    fn pdf(&self, u: f64, v: f64) -> f64 {
335        let u = u.max(1e-10).min(1.0 - 1e-10);
336        let v = v.max(1e-10).min(1.0 - 1e-10);
337        let x = norm_ppf(u);
338        let y = norm_ppf(v);
339        let r = self.rho;
340        let det = 1.0 - r * r;
341        if det <= 0.0 {
342            return 0.0;
343        }
344        let exponent = -(r * r * (x * x + y * y) - 2.0 * r * x * y) / (2.0 * det);
345        exponent.exp() / det.sqrt()
346    }
347
348    fn conditional_v_given_u(&self, u: f64, v: f64) -> f64 {
349        let u = u.max(1e-10).min(1.0 - 1e-10);
350        let v = v.max(1e-10).min(1.0 - 1e-10);
351        let x = norm_ppf(u);
352        let y = norm_ppf(v);
353        let r = self.rho;
354        let det = 1.0 - r * r;
355        if det <= 0.0 {
356            return v;
357        }
358        norm_cdf((y - r * x) / det.sqrt())
359    }
360
361    fn conditional_u_given_v(&self, u: f64, v: f64) -> f64 {
362        let u = u.max(1e-10).min(1.0 - 1e-10);
363        let v = v.max(1e-10).min(1.0 - 1e-10);
364        let x = norm_ppf(u);
365        let y = norm_ppf(v);
366        let r = self.rho;
367        let det = 1.0 - r * r;
368        if det <= 0.0 {
369            return u;
370        }
371        norm_cdf((x - r * y) / det.sqrt())
372    }
373
374    fn upper_tail_dependence(&self) -> f64 {
375        0.0 // Gaussian copula has no tail dependence
376    }
377
378    fn lower_tail_dependence(&self) -> f64 {
379        0.0
380    }
381
382    fn kendalls_tau(&self) -> f64 {
383        (2.0 / PI) * self.rho.asin()
384    }
385
386    fn family_name(&self) -> &str {
387        "Gaussian"
388    }
389
390    fn n_params(&self) -> usize {
391        1
392    }
393
394    fn params(&self) -> Vec<f64> {
395        vec![self.rho]
396    }
397}
398
399/// Approximate bivariate normal CDF using Drezner-Wesolowsky (1990) method
400fn bivariate_normal_cdf(x: f64, y: f64, rho: f64) -> f64 {
401    if rho.abs() < 1e-10 {
402        return norm_cdf(x) * norm_cdf(y);
403    }
404    if rho.abs() > 0.9999 {
405        if rho > 0.0 {
406            return norm_cdf(x.min(y));
407        } else {
408            return (norm_cdf(x) + norm_cdf(-y) - 1.0).max(0.0);
409        }
410    }
411    // Gauss-Legendre quadrature approximation
412    let a = -(x * x + y * y - 2.0 * rho * x * y) / (2.0 * (1.0 - rho * rho));
413    let sign = if rho >= 0.0 { 1.0 } else { -1.0 };
414    // Simple approximation: product + correction
415    let base = norm_cdf(x) * norm_cdf(y);
416    let correction = sign * norm_pdf(x) * norm_pdf(y) * rho / (1.0 - rho * rho).sqrt();
417    (base + correction * (1.0 - (-a).exp()).max(0.0).min(1.0))
418        .max(0.0)
419        .min(1.0)
420}
421
422// ---------------------------------------------------------------------------
423// Student-t copula
424// ---------------------------------------------------------------------------
425
426/// Student-t copula with correlation rho and degrees of freedom df.
427#[derive(Debug, Clone)]
428pub struct StudentTCopula {
429    /// Correlation parameter
430    pub rho: f64,
431    /// Degrees of freedom
432    pub df: f64,
433}
434
435impl StudentTCopula {
436    /// Create a Student-t copula.
437    pub fn new(rho: f64, df: f64) -> StatsResult<Self> {
438        if rho <= -1.0 || rho >= 1.0 {
439            return Err(StatsError::InvalidArgument(format!(
440                "rho must be in (-1, 1), got {}",
441                rho
442            )));
443        }
444        if df <= 0.0 {
445            return Err(StatsError::InvalidArgument(format!(
446                "df must be positive, got {}",
447                df
448            )));
449        }
450        Ok(Self { rho, df })
451    }
452}
453
454impl Copula for StudentTCopula {
455    fn cdf(&self, u: f64, v: f64) -> f64 {
456        let u = u.max(1e-10).min(1.0 - 1e-10);
457        let v = v.max(1e-10).min(1.0 - 1e-10);
458        let x = student_t_ppf(u, self.df);
459        let y = student_t_ppf(v, self.df);
460        // Approximate bivariate t CDF
461        bivariate_normal_cdf(norm_cdf(x) * 2.0 - 1.0, norm_cdf(y) * 2.0 - 1.0, self.rho)
462            .max(0.0)
463            .min(1.0)
464    }
465
466    fn pdf(&self, u: f64, v: f64) -> f64 {
467        let u = u.max(1e-10).min(1.0 - 1e-10);
468        let v = v.max(1e-10).min(1.0 - 1e-10);
469        let x = student_t_ppf(u, self.df);
470        let y = student_t_ppf(v, self.df);
471        let r = self.rho;
472        let nu = self.df;
473        let det = 1.0 - r * r;
474        if det <= 0.0 {
475            return 0.0;
476        }
477        // c(u,v) = f_{nu,R}(t^{-1}(u), t^{-1}(v)) / (f_nu(t^{-1}(u)) * f_nu(t^{-1}(v)))
478        // Bivariate t density
479        let q = (x * x - 2.0 * r * x * y + y * y) / det;
480        let biv_t = (ln_gamma((nu + 2.0) / 2.0)
481            - ln_gamma(nu / 2.0)
482            - (nu * PI).ln()
483            - 0.5 * det.ln()
484            - ((nu + 2.0) / 2.0) * (1.0 + q / nu).ln())
485        .exp();
486        // Marginal t densities
487        let fx = student_t_pdf(x, nu);
488        let fy = student_t_pdf(y, nu);
489        let denom = fx * fy;
490        if denom < 1e-30 {
491            return 0.0;
492        }
493        biv_t / denom
494    }
495
496    fn conditional_v_given_u(&self, u: f64, v: f64) -> f64 {
497        let u = u.max(1e-10).min(1.0 - 1e-10);
498        let v = v.max(1e-10).min(1.0 - 1e-10);
499        let x = student_t_ppf(u, self.df);
500        let y = student_t_ppf(v, self.df);
501        let r = self.rho;
502        let nu = self.df;
503        let adj_df = nu + 1.0;
504        let scale = ((nu + x * x) * (1.0 - r * r) / adj_df).sqrt();
505        if scale < 1e-15 {
506            return v;
507        }
508        student_t_cdf((y - r * x) / scale, adj_df)
509    }
510
511    fn conditional_u_given_v(&self, u: f64, v: f64) -> f64 {
512        // Symmetric: swap
513        self.conditional_v_given_u(v, u)
514    }
515
516    fn upper_tail_dependence(&self) -> f64 {
517        let nu = self.df;
518        let r = self.rho;
519        // lambda_U = 2 * t_{nu+1}(-sqrt((nu+1)(1-r)/(1+r)))
520        let arg = -((nu + 1.0) * (1.0 - r) / (1.0 + r)).sqrt();
521        2.0 * student_t_cdf(arg, nu + 1.0)
522    }
523
524    fn lower_tail_dependence(&self) -> f64 {
525        // Symmetric for Student-t copula
526        self.upper_tail_dependence()
527    }
528
529    fn kendalls_tau(&self) -> f64 {
530        (2.0 / PI) * self.rho.asin()
531    }
532
533    fn family_name(&self) -> &str {
534        "Student-t"
535    }
536
537    fn n_params(&self) -> usize {
538        2
539    }
540
541    fn params(&self) -> Vec<f64> {
542        vec![self.rho, self.df]
543    }
544}
545
546// ---------------------------------------------------------------------------
547// Clayton copula
548// ---------------------------------------------------------------------------
549
550/// Clayton copula with parameter theta > 0 (or theta in [-1, 0) for negative dependence).
551/// For theta > 0, it exhibits lower tail dependence.
552#[derive(Debug, Clone)]
553pub struct ClaytonCopula {
554    /// Dependence parameter (theta > 0 for positive dependence)
555    pub theta: f64,
556}
557
558impl ClaytonCopula {
559    /// Create a Clayton copula with the given parameter.
560    pub fn new(theta: f64) -> StatsResult<Self> {
561        if theta < -1.0 || (theta.abs() < 1e-15) {
562            return Err(StatsError::InvalidArgument(format!(
563                "Clayton theta must be > 0 (or in [-1, 0)), got {}",
564                theta
565            )));
566        }
567        Ok(Self { theta })
568    }
569}
570
571impl Copula for ClaytonCopula {
572    fn cdf(&self, u: f64, v: f64) -> f64 {
573        let u = u.max(1e-10).min(1.0 - 1e-10);
574        let v = v.max(1e-10).min(1.0 - 1e-10);
575        let th = self.theta;
576        let val = u.powf(-th) + v.powf(-th) - 1.0;
577        if val <= 0.0 {
578            return 0.0;
579        }
580        val.powf(-1.0 / th).max(0.0).min(1.0)
581    }
582
583    fn pdf(&self, u: f64, v: f64) -> f64 {
584        let u = u.max(1e-10).min(1.0 - 1e-10);
585        let v = v.max(1e-10).min(1.0 - 1e-10);
586        let th = self.theta;
587        let a = u.powf(-th) + v.powf(-th) - 1.0;
588        if a <= 0.0 {
589            return 0.0;
590        }
591        (1.0 + th) * (u * v).powf(-th - 1.0) * a.powf(-2.0 - 1.0 / th)
592    }
593
594    fn conditional_v_given_u(&self, u: f64, v: f64) -> f64 {
595        let u = u.max(1e-10).min(1.0 - 1e-10);
596        let v = v.max(1e-10).min(1.0 - 1e-10);
597        let th = self.theta;
598        let a = u.powf(-th) + v.powf(-th) - 1.0;
599        if a <= 0.0 {
600            return v;
601        }
602        u.powf(-th - 1.0) * a.powf(-1.0 - 1.0 / th)
603    }
604
605    fn conditional_u_given_v(&self, u: f64, v: f64) -> f64 {
606        // Symmetric: swap arguments in formula
607        let u = u.max(1e-10).min(1.0 - 1e-10);
608        let v = v.max(1e-10).min(1.0 - 1e-10);
609        let th = self.theta;
610        let a = u.powf(-th) + v.powf(-th) - 1.0;
611        if a <= 0.0 {
612            return u;
613        }
614        v.powf(-th - 1.0) * a.powf(-1.0 - 1.0 / th)
615    }
616
617    fn upper_tail_dependence(&self) -> f64 {
618        0.0 // Clayton has no upper tail dependence
619    }
620
621    fn lower_tail_dependence(&self) -> f64 {
622        if self.theta > 0.0 {
623            2.0_f64.powf(-1.0 / self.theta)
624        } else {
625            0.0
626        }
627    }
628
629    fn kendalls_tau(&self) -> f64 {
630        self.theta / (self.theta + 2.0)
631    }
632
633    fn family_name(&self) -> &str {
634        "Clayton"
635    }
636
637    fn n_params(&self) -> usize {
638        1
639    }
640
641    fn params(&self) -> Vec<f64> {
642        vec![self.theta]
643    }
644}
645
646// ---------------------------------------------------------------------------
647// Gumbel copula
648// ---------------------------------------------------------------------------
649
650/// Gumbel copula with parameter theta >= 1.
651/// Exhibits upper tail dependence.
652#[derive(Debug, Clone)]
653pub struct GumbelCopula {
654    /// Dependence parameter (theta >= 1; theta=1 gives independence)
655    pub theta: f64,
656}
657
658impl GumbelCopula {
659    /// Create a Gumbel copula.
660    pub fn new(theta: f64) -> StatsResult<Self> {
661        if theta < 1.0 {
662            return Err(StatsError::InvalidArgument(format!(
663                "Gumbel theta must be >= 1, got {}",
664                theta
665            )));
666        }
667        Ok(Self { theta })
668    }
669}
670
671impl Copula for GumbelCopula {
672    fn cdf(&self, u: f64, v: f64) -> f64 {
673        let u = u.max(1e-10).min(1.0 - 1e-10);
674        let v = v.max(1e-10).min(1.0 - 1e-10);
675        let th = self.theta;
676        let a = (-u.ln()).powf(th) + (-v.ln()).powf(th);
677        (-a.powf(1.0 / th)).exp()
678    }
679
680    fn pdf(&self, u: f64, v: f64) -> f64 {
681        let u = u.max(1e-10).min(1.0 - 1e-10);
682        let v = v.max(1e-10).min(1.0 - 1e-10);
683        let th = self.theta;
684        let lu = -u.ln();
685        let lv = -v.ln();
686        let lu_th = lu.powf(th);
687        let lv_th = lv.powf(th);
688        let a = lu_th + lv_th;
689        let c_val = (-a.powf(1.0 / th)).exp();
690        if c_val < 1e-30 {
691            return 0.0;
692        }
693        let a_inv = a.powf(1.0 / th);
694        let term1 = (lu * lv).powf(th - 1.0);
695        let term2 = a.powf(2.0 / th - 2.0);
696        let term3 = a_inv + th - 1.0;
697        c_val * term1 * term2 * term3 / (u * v)
698    }
699
700    fn conditional_v_given_u(&self, u: f64, v: f64) -> f64 {
701        let u = u.max(1e-10).min(1.0 - 1e-10);
702        let v = v.max(1e-10).min(1.0 - 1e-10);
703        let th = self.theta;
704        let lu = -u.ln();
705        let lv = -v.ln();
706        let a = lu.powf(th) + lv.powf(th);
707        let c_val = (-a.powf(1.0 / th)).exp();
708        c_val * lu.powf(th - 1.0) * a.powf(1.0 / th - 1.0) / u
709    }
710
711    fn conditional_u_given_v(&self, u: f64, v: f64) -> f64 {
712        let u = u.max(1e-10).min(1.0 - 1e-10);
713        let v = v.max(1e-10).min(1.0 - 1e-10);
714        let th = self.theta;
715        let lu = -u.ln();
716        let lv = -v.ln();
717        let a = lu.powf(th) + lv.powf(th);
718        let c_val = (-a.powf(1.0 / th)).exp();
719        c_val * lv.powf(th - 1.0) * a.powf(1.0 / th - 1.0) / v
720    }
721
722    fn upper_tail_dependence(&self) -> f64 {
723        2.0 - 2.0_f64.powf(1.0 / self.theta)
724    }
725
726    fn lower_tail_dependence(&self) -> f64 {
727        0.0 // Gumbel has no lower tail dependence
728    }
729
730    fn kendalls_tau(&self) -> f64 {
731        1.0 - 1.0 / self.theta
732    }
733
734    fn family_name(&self) -> &str {
735        "Gumbel"
736    }
737
738    fn n_params(&self) -> usize {
739        1
740    }
741
742    fn params(&self) -> Vec<f64> {
743        vec![self.theta]
744    }
745}
746
747// ---------------------------------------------------------------------------
748// Frank copula
749// ---------------------------------------------------------------------------
750
751/// Frank copula with parameter theta != 0.
752/// Symmetric copula with no tail dependence.
753#[derive(Debug, Clone)]
754pub struct FrankCopula {
755    /// Dependence parameter (theta != 0; positive = positive dependence)
756    pub theta: f64,
757}
758
759impl FrankCopula {
760    /// Create a Frank copula.
761    pub fn new(theta: f64) -> StatsResult<Self> {
762        if theta.abs() < 1e-15 {
763            return Err(StatsError::InvalidArgument(
764                "Frank theta must be != 0".into(),
765            ));
766        }
767        Ok(Self { theta })
768    }
769}
770
771impl Copula for FrankCopula {
772    fn cdf(&self, u: f64, v: f64) -> f64 {
773        let u = u.max(1e-10).min(1.0 - 1e-10);
774        let v = v.max(1e-10).min(1.0 - 1e-10);
775        let th = self.theta;
776        let num = ((-th * u).exp() - 1.0) * ((-th * v).exp() - 1.0);
777        let denom = (-th).exp() - 1.0;
778        if denom.abs() < 1e-30 {
779            return u * v;
780        }
781        -(1.0 + num / denom).max(1e-30).ln() / th
782    }
783
784    fn pdf(&self, u: f64, v: f64) -> f64 {
785        let u = u.max(1e-10).min(1.0 - 1e-10);
786        let v = v.max(1e-10).min(1.0 - 1e-10);
787        let th = self.theta;
788        let e_th = (-th).exp();
789        let e_u = (-th * u).exp();
790        let e_v = (-th * v).exp();
791        let num = -th * (e_th - 1.0) * (-th * (u + v)).exp();
792        let denom_inner = (e_th - 1.0) + (e_u - 1.0) * (e_v - 1.0);
793        if denom_inner.abs() < 1e-30 {
794            return 0.0;
795        }
796        let denom = denom_inner * denom_inner;
797        (num / denom).abs()
798    }
799
800    fn conditional_v_given_u(&self, u: f64, v: f64) -> f64 {
801        let u = u.max(1e-10).min(1.0 - 1e-10);
802        let v = v.max(1e-10).min(1.0 - 1e-10);
803        let th = self.theta;
804        let e_u = (-th * u).exp();
805        let e_v = (-th * v).exp();
806        let e_th = (-th).exp();
807        let num = e_u * (e_v - 1.0);
808        let denom = (e_th - 1.0) + (e_u - 1.0) * (e_v - 1.0);
809        if denom.abs() < 1e-30 {
810            return v;
811        }
812        (num / denom).max(0.0).min(1.0)
813    }
814
815    fn conditional_u_given_v(&self, u: f64, v: f64) -> f64 {
816        let u = u.max(1e-10).min(1.0 - 1e-10);
817        let v = v.max(1e-10).min(1.0 - 1e-10);
818        let th = self.theta;
819        let e_u = (-th * u).exp();
820        let e_v = (-th * v).exp();
821        let e_th = (-th).exp();
822        let num = e_v * (e_u - 1.0);
823        let denom = (e_th - 1.0) + (e_u - 1.0) * (e_v - 1.0);
824        if denom.abs() < 1e-30 {
825            return u;
826        }
827        (num / denom).max(0.0).min(1.0)
828    }
829
830    fn upper_tail_dependence(&self) -> f64 {
831        0.0
832    }
833
834    fn lower_tail_dependence(&self) -> f64 {
835        0.0
836    }
837
838    fn kendalls_tau(&self) -> f64 {
839        let th = self.theta;
840        if th.abs() < 0.01 {
841            return th / 9.0; // small theta approximation
842        }
843        // tau = 1 - 4/theta * (1 - D_1(theta)/theta)
844        // where D_1 is the first Debye function
845        let d1 = debye_1(th);
846        1.0 - 4.0 * (1.0 - d1) / th
847    }
848
849    fn family_name(&self) -> &str {
850        "Frank"
851    }
852
853    fn n_params(&self) -> usize {
854        1
855    }
856
857    fn params(&self) -> Vec<f64> {
858        vec![self.theta]
859    }
860}
861
862/// First Debye function: D_1(x) = (1/x) * integral_0^x t/(e^t - 1) dt
863///
864/// For negative x the integral runs from 0 to x (i.e., the signed path), which
865/// gives D_1(x) > 1 for x < 0 because t/(e^t - 1) > 1 for t < 0.  Using
866/// x.abs() in the step would collapse the negative case onto the positive one
867/// and produce the wrong sign of Kendall's tau for the Frank copula.
868fn debye_1(x: f64) -> f64 {
869    if x.abs() < 1e-10 {
870        return 1.0;
871    }
872    // Numerical integration with Simpson's rule along the signed path [0, x].
873    // h = x / n is signed: negative when x < 0.
874    let n = 100;
875    let h = x / (n as f64); // signed step
876    let mut sum = 0.0;
877    for i in 0..=n {
878        let t = (i as f64) * h; // signed t
879        let f = if t.abs() < 1e-10 {
880            1.0 // limit of t/(e^t - 1) as t->0
881        } else {
882            t / (t.exp() - 1.0)
883        };
884        let w = if i == 0 || i == n {
885            1.0
886        } else if i % 2 == 0 {
887            2.0
888        } else {
889            4.0
890        };
891        sum += w * f;
892    }
893    // sum * h / (3 * x) = sum * (x/n) / (3*x) = sum / (3*n) which is sign-independent,
894    // but we keep the formula as sum*h/(3*x) so that the sign of h and x cancel out
895    // correctly (h/x = 1/n > 0 always).
896    sum * h / (3.0 * x)
897}
898
899// ---------------------------------------------------------------------------
900// Tail dependence coefficients
901// ---------------------------------------------------------------------------
902
903/// Compute the tail dependence coefficients for a given copula.
904#[derive(Debug, Clone)]
905pub struct TailDependence {
906    /// Upper tail dependence coefficient lambda_U
907    pub upper: f64,
908    /// Lower tail dependence coefficient lambda_L
909    pub lower: f64,
910}
911
912/// Compute tail dependence for any copula.
913pub fn tail_dependence(copula: &dyn Copula) -> TailDependence {
914    TailDependence {
915        upper: copula.upper_tail_dependence(),
916        lower: copula.lower_tail_dependence(),
917    }
918}
919
920// ---------------------------------------------------------------------------
921// Maximum pseudo-likelihood fitting
922// ---------------------------------------------------------------------------
923
924/// Result of copula fitting
925#[derive(Debug, Clone)]
926pub struct CopulaFitResult {
927    /// Estimated parameters
928    pub params: Vec<f64>,
929    /// Log pseudo-likelihood
930    pub log_likelihood: f64,
931    /// AIC
932    pub aic: f64,
933    /// BIC
934    pub bic: f64,
935    /// Family name
936    pub family: String,
937}
938
939/// Fit a Gaussian copula to pseudo-observations using maximum pseudo-likelihood.
940///
941/// # Arguments
942/// * `u` - First marginal pseudo-observations (should be in (0, 1))
943/// * `v` - Second marginal pseudo-observations
944///
945/// # Example
946/// ```
947/// use scirs2_stats::copulas::fit_gaussian_copula;
948/// use scirs2_core::ndarray::Array1;
949///
950/// // Generate pseudo-observations with positive dependence
951/// let u = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9, 0.95]);
952/// let v = Array1::from_vec(vec![0.15, 0.25, 0.35, 0.45, 0.65, 0.85, 0.88, 0.92]);
953/// let result = fit_gaussian_copula(&u.view(), &v.view()).expect("fit failed");
954/// assert!(result.params[0] > 0.0); // positive dependence
955/// ```
956pub fn fit_gaussian_copula(
957    u: &ArrayView1<f64>,
958    v: &ArrayView1<f64>,
959) -> StatsResult<CopulaFitResult> {
960    if u.len() != v.len() {
961        return Err(StatsError::DimensionMismatch(
962            "u and v must have the same length".into(),
963        ));
964    }
965    let n = u.len();
966    if n < 3 {
967        return Err(StatsError::InsufficientData(
968            "need at least 3 observations for copula fitting".into(),
969        ));
970    }
971    // Grid search + refinement for rho
972    let mut best_rho = 0.0;
973    let mut best_ll = f64::NEG_INFINITY;
974    // Coarse grid
975    for i in -19..20 {
976        let rho = (i as f64) * 0.05;
977        if rho.abs() >= 0.999 {
978            continue;
979        }
980        let cop = match GaussianCopula::new(rho) {
981            Ok(c) => c,
982            Err(_) => continue,
983        };
984        let ll = pseudo_log_likelihood(u, v, &cop);
985        if ll > best_ll {
986            best_ll = ll;
987            best_rho = rho;
988        }
989    }
990    // Fine refinement
991    let mut step = 0.025;
992    for _ in 0..20 {
993        let ll_plus = {
994            let r = (best_rho + step).min(0.999);
995            let cop = match GaussianCopula::new(r) {
996                Ok(c) => c,
997                Err(_) => continue,
998            };
999            pseudo_log_likelihood(u, v, &cop)
1000        };
1001        let ll_minus = {
1002            let r = (best_rho - step).max(-0.999);
1003            let cop = match GaussianCopula::new(r) {
1004                Ok(c) => c,
1005                Err(_) => continue,
1006            };
1007            pseudo_log_likelihood(u, v, &cop)
1008        };
1009        if ll_plus > best_ll {
1010            best_rho = (best_rho + step).min(0.999);
1011            best_ll = ll_plus;
1012        } else if ll_minus > best_ll {
1013            best_rho = (best_rho - step).max(-0.999);
1014            best_ll = ll_minus;
1015        }
1016        step *= 0.5;
1017    }
1018
1019    let nf = n as f64;
1020    let k = 1.0;
1021    Ok(CopulaFitResult {
1022        params: vec![best_rho],
1023        log_likelihood: best_ll,
1024        aic: -2.0 * best_ll + 2.0 * k,
1025        bic: -2.0 * best_ll + k * nf.ln(),
1026        family: "Gaussian".into(),
1027    })
1028}
1029
1030/// Fit a Clayton copula to pseudo-observations.
1031pub fn fit_clayton_copula(
1032    u: &ArrayView1<f64>,
1033    v: &ArrayView1<f64>,
1034) -> StatsResult<CopulaFitResult> {
1035    fit_archimedean(u, v, "Clayton")
1036}
1037
1038/// Fit a Gumbel copula to pseudo-observations.
1039pub fn fit_gumbel_copula(u: &ArrayView1<f64>, v: &ArrayView1<f64>) -> StatsResult<CopulaFitResult> {
1040    fit_archimedean(u, v, "Gumbel")
1041}
1042
1043/// Fit a Frank copula to pseudo-observations.
1044pub fn fit_frank_copula(u: &ArrayView1<f64>, v: &ArrayView1<f64>) -> StatsResult<CopulaFitResult> {
1045    fit_archimedean(u, v, "Frank")
1046}
1047
1048fn fit_archimedean(
1049    u: &ArrayView1<f64>,
1050    v: &ArrayView1<f64>,
1051    family: &str,
1052) -> StatsResult<CopulaFitResult> {
1053    if u.len() != v.len() {
1054        return Err(StatsError::DimensionMismatch(
1055            "u and v must have the same length".into(),
1056        ));
1057    }
1058    let n = u.len();
1059    if n < 3 {
1060        return Err(StatsError::InsufficientData(
1061            "need at least 3 observations".into(),
1062        ));
1063    }
1064    // Estimate Kendall's tau from data
1065    let tau = sample_kendalls_tau(u, v)?;
1066
1067    // Invert tau to get theta
1068    let theta_init = match family {
1069        "Clayton" => {
1070            // tau = theta / (theta + 2)  =>  theta = 2*tau / (1 - tau)
1071            if tau <= 0.0 {
1072                0.1
1073            } else {
1074                (2.0 * tau / (1.0 - tau).max(0.01)).max(0.01)
1075            }
1076        }
1077        "Gumbel" => {
1078            // tau = 1 - 1/theta  =>  theta = 1/(1 - tau)
1079            (1.0 / (1.0 - tau).max(0.01)).max(1.0)
1080        }
1081        "Frank" => {
1082            // Approximate inverse for Frank's tau-theta relation
1083            if tau.abs() < 0.01 {
1084                0.1
1085            } else {
1086                tau * 9.0
1087            } // rough starting point
1088        }
1089        _ => 1.0,
1090    };
1091
1092    // Grid search around the initial estimate
1093    let mut best_theta = theta_init;
1094    let mut best_ll = f64::NEG_INFINITY;
1095    let (lo, hi, step_count) = match family {
1096        "Clayton" => (0.01, 20.0, 200),
1097        "Gumbel" => (1.0, 20.0, 200),
1098        "Frank" => (-20.0, 20.0, 200),
1099        _ => (0.01, 20.0, 200),
1100    };
1101    for i in 0..=step_count {
1102        let theta = lo + (hi - lo) * (i as f64) / (step_count as f64);
1103        let copula: Box<dyn Copula> = match family {
1104            "Clayton" => match ClaytonCopula::new(theta) {
1105                Ok(c) => Box::new(c),
1106                Err(_) => continue,
1107            },
1108            "Gumbel" => match GumbelCopula::new(theta) {
1109                Ok(c) => Box::new(c),
1110                Err(_) => continue,
1111            },
1112            "Frank" => match FrankCopula::new(theta) {
1113                Ok(c) => Box::new(c),
1114                Err(_) => continue,
1115            },
1116            _ => continue,
1117        };
1118        let ll = pseudo_log_likelihood(u, v, copula.as_ref());
1119        if ll > best_ll && ll.is_finite() {
1120            best_ll = ll;
1121            best_theta = theta;
1122        }
1123    }
1124
1125    let nf = n as f64;
1126    let k = 1.0;
1127    Ok(CopulaFitResult {
1128        params: vec![best_theta],
1129        log_likelihood: best_ll,
1130        aic: -2.0 * best_ll + 2.0 * k,
1131        bic: -2.0 * best_ll + k * nf.ln(),
1132        family: family.into(),
1133    })
1134}
1135
1136/// Compute the pseudo-log-likelihood for a copula given observations in (0, 1).
1137fn pseudo_log_likelihood(u: &ArrayView1<f64>, v: &ArrayView1<f64>, copula: &dyn Copula) -> f64 {
1138    let n = u.len();
1139    let mut ll = 0.0;
1140    for i in 0..n {
1141        let ui = u[i].max(1e-6).min(1.0 - 1e-6);
1142        let vi = v[i].max(1e-6).min(1.0 - 1e-6);
1143        let log_c = copula.log_pdf(ui, vi);
1144        if log_c.is_finite() {
1145            ll += log_c;
1146        } else {
1147            ll += -50.0; // penalty for extreme values
1148        }
1149    }
1150    ll
1151}
1152
1153/// Compute Kendall's tau from paired observations.
1154fn sample_kendalls_tau(u: &ArrayView1<f64>, v: &ArrayView1<f64>) -> StatsResult<f64> {
1155    let n = u.len();
1156    if n < 2 {
1157        return Err(StatsError::InsufficientData(
1158            "need at least 2 observations for Kendall's tau".into(),
1159        ));
1160    }
1161    let mut concordant = 0_i64;
1162    let mut discordant = 0_i64;
1163    for i in 0..n {
1164        for j in (i + 1)..n {
1165            let du = u[i] - u[j];
1166            let dv = v[i] - v[j];
1167            let prod = du * dv;
1168            if prod > 0.0 {
1169                concordant += 1;
1170            } else if prod < 0.0 {
1171                discordant += 1;
1172            }
1173        }
1174    }
1175    let total = concordant + discordant;
1176    if total == 0 {
1177        return Ok(0.0);
1178    }
1179    Ok((concordant - discordant) as f64 / total as f64)
1180}
1181
1182/// Convert data to pseudo-observations (empirical CDF values).
1183///
1184/// For each variable, replaces values with their ranks divided by (n+1).
1185pub fn pseudo_observations(
1186    x: &ArrayView1<f64>,
1187    y: &ArrayView1<f64>,
1188) -> StatsResult<(Array1<f64>, Array1<f64>)> {
1189    let n = x.len();
1190    if n != y.len() {
1191        return Err(StatsError::DimensionMismatch(
1192            "x and y must have the same length".into(),
1193        ));
1194    }
1195    if n < 2 {
1196        return Err(StatsError::InsufficientData(
1197            "need at least 2 observations".into(),
1198        ));
1199    }
1200    let u = rank_transform(x);
1201    let v = rank_transform(y);
1202    Ok((u, v))
1203}
1204
1205fn rank_transform(x: &ArrayView1<f64>) -> Array1<f64> {
1206    let n = x.len();
1207    let mut indices: Vec<usize> = (0..n).collect();
1208    indices.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal));
1209    let mut ranks = Array1::<f64>::zeros(n);
1210    for (rank, &idx) in indices.iter().enumerate() {
1211        ranks[idx] = (rank as f64 + 1.0) / (n as f64 + 1.0);
1212    }
1213    ranks
1214}
1215
1216// ---------------------------------------------------------------------------
1217// Tests
1218// ---------------------------------------------------------------------------
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223    use scirs2_core::ndarray::Array1;
1224
1225    #[test]
1226    fn test_gaussian_copula_cdf_bounds() {
1227        let cop = GaussianCopula::new(0.5).expect("should create Gaussian copula");
1228        let c = cop.cdf(0.5, 0.5);
1229        assert!(c >= 0.0 && c <= 1.0, "CDF should be in [0,1], got {}", c);
1230        let c00 = cop.cdf(0.0, 0.0);
1231        assert!(c00 >= 0.0);
1232        let c11 = cop.cdf(1.0, 1.0);
1233        assert!(c11 <= 1.0 + 1e-10);
1234    }
1235
1236    #[test]
1237    fn test_gaussian_copula_pdf_positive() {
1238        let cop = GaussianCopula::new(0.3).expect("should create");
1239        let d = cop.pdf(0.5, 0.5);
1240        assert!(d > 0.0, "PDF should be positive at interior, got {}", d);
1241    }
1242
1243    #[test]
1244    fn test_gaussian_copula_no_tail_dependence() {
1245        let cop = GaussianCopula::new(0.8).expect("should create");
1246        assert!((cop.upper_tail_dependence()).abs() < 1e-10);
1247        assert!((cop.lower_tail_dependence()).abs() < 1e-10);
1248    }
1249
1250    #[test]
1251    fn test_gaussian_copula_kendalls_tau() {
1252        let cop = GaussianCopula::new(0.0).expect("should create");
1253        assert!((cop.kendalls_tau()).abs() < 1e-10);
1254        let cop2 = GaussianCopula::new(0.5).expect("should create");
1255        let tau = cop2.kendalls_tau();
1256        assert!(tau > 0.0 && tau < 1.0);
1257    }
1258
1259    #[test]
1260    fn test_gaussian_copula_invalid_rho() {
1261        assert!(GaussianCopula::new(1.0).is_err());
1262        assert!(GaussianCopula::new(-1.0).is_err());
1263        assert!(GaussianCopula::new(1.5).is_err());
1264    }
1265
1266    #[test]
1267    fn test_student_t_copula_basic() {
1268        let cop = StudentTCopula::new(0.5, 4.0).expect("should create");
1269        let c = cop.cdf(0.5, 0.5);
1270        assert!(c >= 0.0 && c <= 1.0);
1271        let d = cop.pdf(0.5, 0.5);
1272        assert!(d > 0.0);
1273    }
1274
1275    #[test]
1276    fn test_student_t_copula_tail_dependence() {
1277        let cop = StudentTCopula::new(0.5, 4.0).expect("should create");
1278        let lambda_u = cop.upper_tail_dependence();
1279        let lambda_l = cop.lower_tail_dependence();
1280        assert!(lambda_u > 0.0, "t-copula should have upper tail dep");
1281        assert!(lambda_l > 0.0, "t-copula should have lower tail dep");
1282        // Symmetric
1283        assert!((lambda_u - lambda_l).abs() < 1e-10);
1284    }
1285
1286    #[test]
1287    fn test_student_t_invalid() {
1288        assert!(StudentTCopula::new(0.5, 0.0).is_err());
1289        assert!(StudentTCopula::new(1.0, 4.0).is_err());
1290    }
1291
1292    #[test]
1293    fn test_clayton_copula_cdf() {
1294        let cop = ClaytonCopula::new(2.0).expect("should create");
1295        let c = cop.cdf(0.5, 0.5);
1296        assert!(c >= 0.0 && c <= 1.0);
1297        // C(u, 1) should be u
1298        let c_u1 = cop.cdf(0.3, 0.9999);
1299        assert!((c_u1 - 0.3).abs() < 0.05, "C(u,1) ~ u, got {}", c_u1);
1300    }
1301
1302    #[test]
1303    fn test_clayton_lower_tail_dependence() {
1304        let cop = ClaytonCopula::new(2.0).expect("should create");
1305        let lambda_l = cop.lower_tail_dependence();
1306        assert!(lambda_l > 0.0);
1307        assert!((cop.upper_tail_dependence()).abs() < 1e-10);
1308    }
1309
1310    #[test]
1311    fn test_clayton_kendalls_tau() {
1312        let cop = ClaytonCopula::new(2.0).expect("should create");
1313        let tau = cop.kendalls_tau();
1314        assert!(
1315            (tau - 0.5).abs() < 1e-10,
1316            "tau = theta/(theta+2) = 2/4 = 0.5, got {}",
1317            tau
1318        );
1319    }
1320
1321    #[test]
1322    fn test_clayton_invalid() {
1323        assert!(ClaytonCopula::new(0.0).is_err());
1324    }
1325
1326    #[test]
1327    fn test_gumbel_copula_cdf() {
1328        let cop = GumbelCopula::new(2.0).expect("should create");
1329        let c = cop.cdf(0.5, 0.5);
1330        assert!(c >= 0.0 && c <= 1.0);
1331        // Independence at theta=1
1332        let ind = GumbelCopula::new(1.0).expect("should create");
1333        let c_ind = ind.cdf(0.5, 0.5);
1334        assert!(
1335            (c_ind - 0.25).abs() < 0.01,
1336            "theta=1 => independence, got {}",
1337            c_ind
1338        );
1339    }
1340
1341    #[test]
1342    fn test_gumbel_upper_tail_dependence() {
1343        let cop = GumbelCopula::new(2.0).expect("should create");
1344        let lambda_u = cop.upper_tail_dependence();
1345        assert!(lambda_u > 0.0);
1346        assert!((cop.lower_tail_dependence()).abs() < 1e-10);
1347    }
1348
1349    #[test]
1350    fn test_gumbel_kendalls_tau() {
1351        let cop = GumbelCopula::new(2.0).expect("should create");
1352        let tau = cop.kendalls_tau();
1353        assert!(
1354            (tau - 0.5).abs() < 1e-10,
1355            "tau = 1 - 1/theta = 0.5, got {}",
1356            tau
1357        );
1358    }
1359
1360    #[test]
1361    fn test_gumbel_invalid() {
1362        assert!(GumbelCopula::new(0.5).is_err());
1363    }
1364
1365    #[test]
1366    fn test_frank_copula_cdf() {
1367        let cop = FrankCopula::new(5.0).expect("should create");
1368        let c = cop.cdf(0.5, 0.5);
1369        assert!(c >= 0.0 && c <= 1.0);
1370    }
1371
1372    #[test]
1373    fn test_frank_copula_pdf_positive() {
1374        let cop = FrankCopula::new(5.0).expect("should create");
1375        let d = cop.pdf(0.5, 0.5);
1376        assert!(d > 0.0);
1377    }
1378
1379    #[test]
1380    fn test_frank_no_tail_dependence() {
1381        let cop = FrankCopula::new(10.0).expect("should create");
1382        assert!((cop.upper_tail_dependence()).abs() < 1e-10);
1383        assert!((cop.lower_tail_dependence()).abs() < 1e-10);
1384    }
1385
1386    #[test]
1387    fn test_frank_invalid() {
1388        assert!(FrankCopula::new(0.0).is_err());
1389    }
1390
1391    #[test]
1392    fn test_frank_negative_dependence() {
1393        let cop = FrankCopula::new(-5.0).expect("should create");
1394        let tau = cop.kendalls_tau();
1395        assert!(tau < 0.0, "negative theta => negative tau, got {}", tau);
1396    }
1397
1398    #[test]
1399    fn test_tail_dependence_helper() {
1400        let cop = ClaytonCopula::new(3.0).expect("should create");
1401        let td = tail_dependence(&cop);
1402        assert!(td.lower > 0.0);
1403        assert!(td.upper < 1e-10);
1404    }
1405
1406    #[test]
1407    fn test_pseudo_observations() {
1408        let x = Array1::from_vec(vec![10.0, 20.0, 30.0, 40.0, 50.0]);
1409        let y = Array1::from_vec(vec![50.0, 40.0, 30.0, 20.0, 10.0]);
1410        let (u, v) = pseudo_observations(&x.view(), &y.view()).expect("should succeed");
1411        assert_eq!(u.len(), 5);
1412        assert_eq!(v.len(), 5);
1413        // Rank of smallest x (10.0) should be 1/6
1414        assert!((u[0] - 1.0 / 6.0).abs() < 1e-10);
1415        // Rank of largest x (50.0) should be 5/6
1416        assert!((u[4] - 5.0 / 6.0).abs() < 1e-10);
1417    }
1418
1419    #[test]
1420    fn test_fit_gaussian_copula() {
1421        // Positively dependent data
1422        let u = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]);
1423        let v = Array1::from_vec(vec![0.15, 0.25, 0.28, 0.45, 0.55, 0.62, 0.73, 0.82, 0.88]);
1424        let result = fit_gaussian_copula(&u.view(), &v.view());
1425        assert!(result.is_ok());
1426        let r = result.expect("fit should succeed");
1427        assert!(
1428            r.params[0] > 0.0,
1429            "rho should be positive, got {}",
1430            r.params[0]
1431        );
1432        assert!(r.log_likelihood.is_finite());
1433    }
1434
1435    #[test]
1436    fn test_fit_clayton_copula() {
1437        let u = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]);
1438        let v = Array1::from_vec(vec![0.15, 0.18, 0.35, 0.42, 0.55, 0.58, 0.72, 0.85, 0.92]);
1439        let result = fit_clayton_copula(&u.view(), &v.view());
1440        assert!(result.is_ok());
1441        let r = result.expect("fit should succeed");
1442        assert!(r.params[0] > 0.0);
1443    }
1444
1445    #[test]
1446    fn test_fit_gumbel_copula() {
1447        let u = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9]);
1448        let v = Array1::from_vec(vec![0.15, 0.25, 0.35, 0.52, 0.68, 0.82, 0.88]);
1449        let result = fit_gumbel_copula(&u.view(), &v.view());
1450        assert!(result.is_ok());
1451        let r = result.expect("fit should succeed");
1452        assert!(r.params[0] >= 1.0);
1453    }
1454
1455    #[test]
1456    fn test_fit_frank_copula() {
1457        let u = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9]);
1458        let v = Array1::from_vec(vec![0.15, 0.25, 0.32, 0.48, 0.72, 0.78, 0.92]);
1459        let result = fit_frank_copula(&u.view(), &v.view());
1460        assert!(result.is_ok());
1461    }
1462
1463    #[test]
1464    fn test_fit_insufficient_data() {
1465        let u = Array1::from_vec(vec![0.5]);
1466        let v = Array1::from_vec(vec![0.5]);
1467        assert!(fit_gaussian_copula(&u.view(), &v.view()).is_err());
1468    }
1469
1470    #[test]
1471    fn test_fit_length_mismatch() {
1472        let u = Array1::from_vec(vec![0.1, 0.2, 0.3]);
1473        let v = Array1::from_vec(vec![0.1, 0.2]);
1474        assert!(fit_gaussian_copula(&u.view(), &v.view()).is_err());
1475    }
1476
1477    #[test]
1478    fn test_conditional_gaussian() {
1479        let cop = GaussianCopula::new(0.5).expect("should create");
1480        let c = cop.conditional_v_given_u(0.5, 0.5);
1481        assert!(
1482            c >= 0.0 && c <= 1.0,
1483            "conditional should be in [0,1], got {}",
1484            c
1485        );
1486        // At u=0.5, conditional should be close to 0.5 for v=0.5
1487        assert!((c - 0.5).abs() < 0.15);
1488    }
1489
1490    #[test]
1491    fn test_sample_kendalls_tau() {
1492        let u = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4, 0.5]);
1493        let v = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4, 0.5]);
1494        let tau = sample_kendalls_tau(&u.view(), &v.view()).expect("should succeed");
1495        assert!(
1496            (tau - 1.0).abs() < 1e-10,
1497            "perfect concordance => tau=1, got {}",
1498            tau
1499        );
1500    }
1501
1502    #[test]
1503    fn test_sample_kendalls_tau_negative() {
1504        let u = Array1::from_vec(vec![0.1, 0.2, 0.3, 0.4, 0.5]);
1505        let v = Array1::from_vec(vec![0.5, 0.4, 0.3, 0.2, 0.1]);
1506        let tau = sample_kendalls_tau(&u.view(), &v.view()).expect("should succeed");
1507        assert!(
1508            (tau - (-1.0)).abs() < 1e-10,
1509            "perfect discordance => tau=-1, got {}",
1510            tau
1511        );
1512    }
1513
1514    #[test]
1515    fn test_norm_ppf_roundtrip() {
1516        for &p in &[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99] {
1517            let x = norm_ppf(p);
1518            let q = norm_cdf(x);
1519            assert!(
1520                (q - p).abs() < 1e-6,
1521                "roundtrip failed for p={}: got {}",
1522                p,
1523                q
1524            );
1525        }
1526    }
1527
1528    #[test]
1529    fn test_copula_families_comprehensive() {
1530        // Test all families with moderate parameters
1531        let families: Vec<Box<dyn Copula>> = vec![
1532            Box::new(GaussianCopula::new(0.5).expect("create")),
1533            Box::new(StudentTCopula::new(0.5, 5.0).expect("create")),
1534            Box::new(ClaytonCopula::new(2.0).expect("create")),
1535            Box::new(GumbelCopula::new(2.0).expect("create")),
1536            Box::new(FrankCopula::new(5.0).expect("create")),
1537        ];
1538        for cop in &families {
1539            let c = cop.cdf(0.5, 0.5);
1540            assert!(
1541                c >= 0.0 && c <= 1.0,
1542                "{} CDF out of bounds: {}",
1543                cop.family_name(),
1544                c
1545            );
1546            let d = cop.pdf(0.5, 0.5);
1547            assert!(d >= 0.0, "{} PDF negative: {}", cop.family_name(), d);
1548            let tau = cop.kendalls_tau();
1549            assert!(tau.is_finite(), "{} tau not finite", cop.family_name());
1550            let lu = cop.upper_tail_dependence();
1551            let ll = cop.lower_tail_dependence();
1552            assert!(
1553                lu >= 0.0 && lu <= 1.0,
1554                "{} upper tail dep: {}",
1555                cop.family_name(),
1556                lu
1557            );
1558            assert!(
1559                ll >= 0.0 && ll <= 1.0,
1560                "{} lower tail dep: {}",
1561                cop.family_name(),
1562                ll
1563            );
1564        }
1565    }
1566}