Skip to main content

copula_core/vine/
mod.rs

1//! Vine copulas module.
2//!
3//! Vine copulas (also called pair-copula constructions) are a flexible way to model
4//! high-dimensional dependence structures by decomposing them into bivariate copulas.
5//!
6//! This module implements:
7//! - C-vine (Canonical vine) - star-shaped structure
8//! - D-vine (Drawable vine) - path-shaped structure
9//!
10//! ## Vine Copulas Overview
11//!
12//! A d-dimensional density can be decomposed into:
13//! - d marginal densities
14//! - d(d-1)/2 bivariate copulas (pair-copulas)
15//!
16//! The vine structure determines which variables are coupled and in which order.
17//!
18//! ## Tree layout
19//!
20//! Both constructors take `trees`, where `trees[l]` holds the pair-copulas of
21//! tree `l + 1`. Sampling uses each pair-copula's position in `trees`; the
22//! `var1`, `var2`, and `conditioning_set` labels of a [`PairCopula`] are
23//! descriptive only. With 0-based variable indices:
24//!
25//! - C-vine: `trees[l][e]` couples variables `l` and `l + e + 1`, given
26//!   variables `0..l`.
27//! - D-vine: `trees[l][e]` couples variables `e` and `e + l + 1`, given
28//!   variables `e + 1..=e + l`.
29//!
30//! All pair-copula families in [`CopulaType`] are exchangeable, so the
31//! h-functions do not depend on the argument order within a pair. Gaussian and
32//! Student-t pair-copulas use closed-form h-functions and inverses (Aas et al.,
33//! 2009); the other families differentiate the copula CDF numerically and invert
34//! by bisection.
35//!
36//! ## Bibliography
37//! - Aas, K., et al. (2009). Pair-copula constructions of multiple dependence. *Insurance: Mathematics and Economics*.
38//! - Bedford, T., & Cooke, R. M. (2002). Vines - A new graphical model for dependent random variables.
39//! - Joe, H. (2014). *Dependence Modeling with Copulas*. CRC Press.
40
41use crate::archimedean::{AMHCopula, ClaytonCopula, FrankCopula, GumbelCopula, JoeCopula};
42use crate::elliptical::{GaussianCopula, StudentTCopula};
43use crate::{Copula, CopulaError, Result};
44use nalgebra::DMatrix;
45use rand::{Rng, RngExt};
46use statrs::distribution::{ContinuousCDF, Normal, StudentsT};
47
48/// Probabilities are kept at least this far from 0 and 1 before quantile
49/// transforms, which are infinite at the boundaries.
50const BOUNDARY_EPS: f64 = 1e-12;
51
52fn interior(p: f64) -> f64 {
53    p.clamp(BOUNDARY_EPS, 1.0 - BOUNDARY_EPS)
54}
55
56/// Enum representing different copula types for vine constructions.
57///
58/// Since Rust's trait objects cannot be used with traits that have generic methods,
59/// we use an enum to represent the different copula types.
60#[derive(Clone)]
61pub enum CopulaType {
62    /// Clayton copula
63    Clayton(ClaytonCopula),
64    /// Gumbel copula
65    Gumbel(GumbelCopula),
66    /// Frank copula
67    Frank(FrankCopula),
68    /// Joe copula
69    Joe(JoeCopula),
70    /// Ali-Mikhail-Haq copula
71    AMH(AMHCopula),
72    /// Gaussian copula
73    Gaussian(GaussianCopula),
74    /// Student-t copula
75    StudentT(StudentTCopula),
76}
77
78impl CopulaType {
79    fn dimension(&self) -> usize {
80        match self {
81            CopulaType::Clayton(c) => c.dimension(),
82            CopulaType::Gumbel(c) => c.dimension(),
83            CopulaType::Frank(c) => c.dimension(),
84            CopulaType::Joe(c) => c.dimension(),
85            CopulaType::AMH(c) => c.dimension(),
86            CopulaType::Gaussian(c) => c.dimension(),
87            CopulaType::StudentT(c) => c.dimension(),
88        }
89    }
90
91    /// Evaluate the CDF.
92    fn cdf(&self, u: &[f64]) -> Result<f64> {
93        match self {
94            CopulaType::Clayton(c) => c.cdf(u),
95            CopulaType::Gumbel(c) => c.cdf(u),
96            CopulaType::Frank(c) => c.cdf(u),
97            CopulaType::Joe(c) => c.cdf(u),
98            CopulaType::AMH(c) => c.cdf(u),
99            CopulaType::Gaussian(c) => c.cdf(u),
100            CopulaType::StudentT(c) => c.cdf(u),
101        }
102    }
103}
104
105/// A pair-copula element in the vine structure.
106///
107/// Contains a copula and the conditioning set information.
108#[derive(Clone)]
109pub struct PairCopula {
110    /// The bivariate copula
111    copula: CopulaType,
112    /// Index of first variable
113    var1: usize,
114    /// Index of second variable
115    var2: usize,
116    /// Indices of conditioning variables
117    conditioning_set: Vec<usize>,
118}
119
120impl PairCopula {
121    /// Create a new pair-copula.
122    pub fn new(copula: CopulaType, var1: usize, var2: usize, conditioning_set: Vec<usize>) -> Self {
123        Self {
124            copula,
125            var1,
126            var2,
127            conditioning_set,
128        }
129    }
130
131    /// Index of the first variable coupled by this pair-copula.
132    pub fn var1(&self) -> usize {
133        self.var1
134    }
135
136    /// Index of the second variable coupled by this pair-copula.
137    pub fn var2(&self) -> usize {
138        self.var2
139    }
140
141    /// Indices of the conditioning variables.
142    pub fn conditioning_set(&self) -> &[usize] {
143        &self.conditioning_set
144    }
145
146    /// Conditional distribution function h(u | v) = ∂C(u, v)/∂v.
147    fn h_function(&self, u: f64, v: f64) -> Result<f64> {
148        match &self.copula {
149            CopulaType::Gaussian(c) => gaussian_h(u, v, c.correlation()[(0, 1)]),
150            CopulaType::StudentT(c) => student_t_h(u, v, c.correlation()[(0, 1)], c.df()),
151            _ => self.numerical_h(u, v),
152        }
153    }
154
155    /// Inverse of the h-function in its first argument: returns `u` such that
156    /// h(u | v) = `w`.
157    fn h_inv(&self, w: f64, v: f64) -> Result<f64> {
158        match &self.copula {
159            CopulaType::Gaussian(c) => gaussian_h_inv(w, v, c.correlation()[(0, 1)]),
160            CopulaType::StudentT(c) => student_t_h_inv(w, v, c.correlation()[(0, 1)], c.df()),
161            _ => self.bisect_h_inv(w, v),
162        }
163    }
164
165    /// Central difference of the CDF in `v`, one-sided at the boundaries.
166    ///
167    /// On the boundary of the unit square the copula axioms C(u, 0) = 0 and
168    /// C(u, 1) = u are used instead of evaluating the family's CDF.
169    fn numerical_h(&self, u: f64, v: f64) -> Result<f64> {
170        const STEP: f64 = 1e-6;
171        let u = u.clamp(0.0, 1.0);
172        let cdf = |t: f64| -> Result<f64> {
173            if u == 0.0 || t == 0.0 {
174                Ok(0.0)
175            } else if t == 1.0 {
176                Ok(u)
177            } else if u == 1.0 {
178                Ok(t)
179            } else {
180                self.copula.cdf(&[u, t])
181            }
182        };
183        let lo = (v - STEP).max(0.0);
184        let hi = (v + STEP).min(1.0);
185        Ok(((cdf(hi)? - cdf(lo)?) / (hi - lo)).clamp(0.0, 1.0))
186    }
187
188    fn bisect_h_inv(&self, w: f64, v: f64) -> Result<f64> {
189        let mut lo = 1e-10;
190        let mut hi = 1.0 - 1e-10;
191
192        for _ in 0..50 {
193            let mid = (lo + hi) / 2.0;
194            let h_val = self.numerical_h(mid, v)?;
195
196            if (h_val - w).abs() < 1e-10 {
197                return Ok(mid);
198            }
199
200            if h_val < w {
201                lo = mid;
202            } else {
203                hi = mid;
204            }
205        }
206
207        Ok((lo + hi) / 2.0)
208    }
209}
210
211fn standard_normal() -> Result<Normal> {
212    Normal::new(0.0, 1.0).map_err(|_| CopulaError::computation("failed to create Normal(0,1)"))
213}
214
215fn standard_t(df: f64) -> Result<StudentsT> {
216    StudentsT::new(0.0, 1.0, df)
217        .map_err(|_| CopulaError::computation("failed to create Student's t distribution"))
218}
219
220/// Gaussian pair-copula: h(u | v) = Φ((Φ⁻¹(u) − ρ Φ⁻¹(v)) / √(1 − ρ²)).
221fn gaussian_h(u: f64, v: f64, rho: f64) -> Result<f64> {
222    let normal = standard_normal()?;
223    let x = normal.inverse_cdf(interior(u));
224    let y = normal.inverse_cdf(interior(v));
225    Ok(normal.cdf((x - rho * y) / (1.0 - rho * rho).sqrt()))
226}
227
228/// Inverse of [`gaussian_h`]: Φ(Φ⁻¹(w) √(1 − ρ²) + ρ Φ⁻¹(v)).
229fn gaussian_h_inv(w: f64, v: f64, rho: f64) -> Result<f64> {
230    let normal = standard_normal()?;
231    let x = normal.inverse_cdf(interior(w));
232    let y = normal.inverse_cdf(interior(v));
233    Ok(normal.cdf(x * (1.0 - rho * rho).sqrt() + rho * y))
234}
235
236/// Student-t pair-copula with ν degrees of freedom:
237/// h(u | v) = t_{ν+1}((x − ρ y) / √((ν + y²)(1 − ρ²)/(ν + 1))),
238/// where x = t_ν⁻¹(u) and y = t_ν⁻¹(v).
239fn student_t_h(u: f64, v: f64, rho: f64, df: f64) -> Result<f64> {
240    let t = standard_t(df)?;
241    let t_next = standard_t(df + 1.0)?;
242    let x = t.inverse_cdf(interior(u));
243    let y = t.inverse_cdf(interior(v));
244    let scale = ((df + y * y) * (1.0 - rho * rho) / (df + 1.0)).sqrt();
245    Ok(t_next.cdf((x - rho * y) / scale))
246}
247
248/// Inverse of [`student_t_h`]: t_ν(t_{ν+1}⁻¹(w) · scale + ρ y).
249fn student_t_h_inv(w: f64, v: f64, rho: f64, df: f64) -> Result<f64> {
250    let t = standard_t(df)?;
251    let t_next = standard_t(df + 1.0)?;
252    let y = t.inverse_cdf(interior(v));
253    let scale = ((df + y * y) * (1.0 - rho * rho) / (df + 1.0)).sqrt();
254    Ok(t.cdf(t_next.inverse_cdf(interior(w)) * scale + rho * y))
255}
256
257/// Check the number of trees, the number of pair-copulas in each tree, and
258/// that every pair-copula is bivariate.
259fn validate_trees(kind: &str, dimension: usize, trees: &[Vec<PairCopula>]) -> Result<()> {
260    if dimension < 2 {
261        return Err(CopulaError::invalid_parameter(
262            "dimension must be >= 2 for vine copulas",
263        ));
264    }
265
266    if trees.len() != dimension - 1 {
267        return Err(CopulaError::invalid_parameter(format!(
268            "{} with dimension {} should have {} trees, got {}",
269            kind,
270            dimension,
271            dimension - 1,
272            trees.len()
273        )));
274    }
275
276    for (level, tree) in trees.iter().enumerate() {
277        let expected_pairs = dimension - level - 1;
278        if tree.len() != expected_pairs {
279            return Err(CopulaError::invalid_parameter(format!(
280                "Tree {} should have {} pair-copulas, got {}",
281                level + 1,
282                expected_pairs,
283                tree.len()
284            )));
285        }
286        for (edge, pair) in tree.iter().enumerate() {
287            let pair_dim = pair.copula.dimension();
288            if pair_dim != 2 {
289                return Err(CopulaError::invalid_parameter(format!(
290                    "pair-copula {} of tree {} must be bivariate, got dimension {}",
291                    edge + 1,
292                    level + 1,
293                    pair_dim
294                )));
295            }
296        }
297    }
298
299    Ok(())
300}
301
302/// C-vine copula (Canonical vine).
303///
304/// In a C-vine, each tree has a star structure with one variable as the root.
305/// Tree 1: copulas C_{1,j} for j=2,...,d
306/// Tree 2: copulas C_{2,j|1} for j=3,...,d
307/// etc.
308///
309/// ## Example Structure (4D)
310/// Tree 1: C_{12}, C_{13}, C_{14}
311/// Tree 2: C_{23|1}, C_{24|1}
312/// Tree 3: C_{34|12}
313#[derive(Clone)]
314pub struct CVineCopula {
315    dimension: usize,
316    /// Pair-copulas organized by tree level
317    /// trees[i] contains the pair-copulas for tree i+1
318    trees: Vec<Vec<PairCopula>>,
319}
320
321impl CVineCopula {
322    /// Create a new C-vine copula.
323    ///
324    /// # Arguments
325    /// * `dimension` - Number of dimensions
326    /// * `trees` - Vector of trees, each containing pair-copulas
327    ///
328    /// # Returns
329    /// A new C-vine copula
330    pub fn new(dimension: usize, trees: Vec<Vec<PairCopula>>) -> Result<Self> {
331        validate_trees("C-vine", dimension, &trees)?;
332        Ok(Self { dimension, trees })
333    }
334}
335
336impl Copula for CVineCopula {
337    fn cdf(&self, u: &[f64]) -> Result<f64> {
338        if u.len() != self.dimension {
339            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
340        }
341        crate::error::validate_unit_range(u)?;
342
343        // CDF computation for vine copulas is complex and typically requires numerical integration
344        Err(CopulaError::not_implemented(
345            "C-vine CDF requires specialized numerical methods",
346        ))
347    }
348
349    fn pdf(&self, u: &[f64]) -> Result<f64> {
350        if u.len() != self.dimension {
351            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
352        }
353        crate::error::validate_unit_range(u)?;
354
355        // PDF can be computed as product of all pair-copula densities
356        // But requires computing all conditional values
357        Err(CopulaError::not_implemented(
358            "C-vine PDF computation not yet implemented",
359        ))
360    }
361
362    /// Sample by inverting the Rosenblatt transform (Aas et al., 2009,
363    /// Algorithm 1).
364    ///
365    /// In a C-vine, the conditioning value of tree `k` is
366    /// F(x_k | x_0, ..., x_{k-1}), which is exactly the independent uniform
367    /// `w[k]` drawn for variable `k`. Each variable is therefore obtained by
368    /// applying the inverse h-functions of its pair-copulas from the deepest
369    /// tree to the first.
370    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
371        let d = self.dimension;
372        let mut samples = DMatrix::<f64>::zeros(n, d);
373
374        for row in 0..n {
375            let w: Vec<f64> = (0..d).map(|_| rng.random::<f64>()).collect();
376
377            for i in 0..d {
378                let mut value = w[i];
379                for k in (0..i).rev() {
380                    value = self.trees[k][i - k - 1].h_inv(value, w[k])?;
381                }
382                samples[(row, i)] = value;
383            }
384        }
385
386        Ok(samples)
387    }
388
389    fn dimension(&self) -> usize {
390        self.dimension
391    }
392}
393
394/// D-vine copula (Drawable vine).
395///
396/// In a D-vine, each tree has a path structure.
397/// Tree 1: copulas C_{j,j+1} for j=1,...,d-1
398/// Tree 2: copulas C_{j,j+2|j+1} for j=1,...,d-2
399/// etc.
400///
401/// ## Example Structure (4D)
402/// Tree 1: C_{12}, C_{23}, C_{34}
403/// Tree 2: C_{13|2}, C_{24|3}
404/// Tree 3: C_{14|23}
405#[derive(Clone)]
406pub struct DVineCopula {
407    dimension: usize,
408    /// Pair-copulas organized by tree level
409    trees: Vec<Vec<PairCopula>>,
410}
411
412impl DVineCopula {
413    /// Create a new D-vine copula.
414    ///
415    /// # Arguments
416    /// * `dimension` - Number of dimensions
417    /// * `trees` - Vector of trees, each containing pair-copulas
418    ///
419    /// # Returns
420    /// A new D-vine copula
421    pub fn new(dimension: usize, trees: Vec<Vec<PairCopula>>) -> Result<Self> {
422        validate_trees("D-vine", dimension, &trees)?;
423        Ok(Self { dimension, trees })
424    }
425}
426
427impl Copula for DVineCopula {
428    fn cdf(&self, u: &[f64]) -> Result<f64> {
429        if u.len() != self.dimension {
430            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
431        }
432        crate::error::validate_unit_range(u)?;
433
434        Err(CopulaError::not_implemented(
435            "D-vine CDF requires specialized numerical methods",
436        ))
437    }
438
439    fn pdf(&self, u: &[f64]) -> Result<f64> {
440        if u.len() != self.dimension {
441            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
442        }
443        crate::error::validate_unit_range(u)?;
444
445        Err(CopulaError::not_implemented(
446            "D-vine PDF computation not yet implemented",
447        ))
448    }
449
450    /// Sample by inverting the Rosenblatt transform (Aas et al., 2009,
451    /// Algorithm 2).
452    ///
453    /// Two tables of conditional distribution values are kept, indexed by
454    /// variable `j` and by `k`, the size of the conditioning set plus one:
455    ///
456    /// - `fwd[j][k]` = F(x_j | x_{j-k+1}, ..., x_{j-1})
457    /// - `bwd[j][k]` = F(x_j | x_{j+1}, ..., x_{j+k-1})
458    ///
459    /// with `fwd[j][1] = bwd[j][1] = u_j`. They satisfy
460    ///
461    /// - `fwd[i][k+1] = h(fwd[i][k] | bwd[i-k][k])` using `trees[k-1][i-k]`
462    /// - `bwd[j][k+1] = h(bwd[j][k] | fwd[j+k][k])` using `trees[k-1][j]`
463    ///
464    /// Variable `i` is drawn by setting `fwd[i][i+1] = w[i]` and inverting the
465    /// first recursion down to `fwd[i][1]`; the second recursion then extends
466    /// `bwd` for the variables that follow.
467    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
468        let d = self.dimension;
469        let mut samples = DMatrix::<f64>::zeros(n, d);
470        let mut fwd = vec![vec![0.0; d + 1]; d];
471        let mut bwd = vec![vec![0.0; d + 1]; d];
472
473        for row in 0..n {
474            let w: Vec<f64> = (0..d).map(|_| rng.random::<f64>()).collect();
475
476            for i in 0..d {
477                let mut value = w[i];
478                for k in (1..=i).rev() {
479                    value = self.trees[k - 1][i - k].h_inv(value, bwd[i - k][k])?;
480                    fwd[i][k] = value;
481                }
482                fwd[i][1] = value;
483                bwd[i][1] = value;
484                samples[(row, i)] = value;
485
486                for k in 1..=i {
487                    bwd[i - k][k + 1] =
488                        self.trees[k - 1][i - k].h_function(bwd[i - k][k], fwd[i][k])?;
489                }
490            }
491        }
492
493        Ok(samples)
494    }
495
496    fn dimension(&self) -> usize {
497        self.dimension
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_pair_copula_creation() {
507        let clayton = CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
508        let pair = PairCopula::new(clayton, 0, 1, vec![]);
509
510        assert_eq!(pair.var1(), 0);
511        assert_eq!(pair.var2(), 1);
512        assert!(pair.conditioning_set().is_empty());
513    }
514
515    #[test]
516    fn test_cvine_creation() {
517        // Create a simple 3D C-vine
518        let c12 = CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
519        let c13 = CopulaType::Clayton(ClaytonCopula::new(1.5).unwrap());
520        let c23_1 = CopulaType::Clayton(ClaytonCopula::new(1.0).unwrap());
521
522        let tree1 = vec![
523            PairCopula::new(c12, 0, 1, vec![]),
524            PairCopula::new(c13, 0, 2, vec![]),
525        ];
526
527        let tree2 = vec![PairCopula::new(c23_1, 1, 2, vec![0])];
528
529        let cvine = CVineCopula::new(3, vec![tree1, tree2]).unwrap();
530        assert_eq!(cvine.dimension(), 3);
531    }
532
533    #[test]
534    fn test_cvine_wrong_num_trees() {
535        let c12 = CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
536        let tree1 = vec![PairCopula::new(c12, 0, 1, vec![])];
537
538        // 3D C-vine should have 2 trees, not 1
539        let result = CVineCopula::new(3, vec![tree1]);
540        assert!(result.is_err());
541    }
542
543    #[test]
544    fn test_cvine_wrong_num_pairs_in_tree() {
545        let clayton = || CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
546        // Tree 1 of a 3D C-vine needs 2 pair-copulas.
547        let tree1 = vec![PairCopula::new(clayton(), 0, 1, vec![])];
548        let tree2 = vec![PairCopula::new(clayton(), 1, 2, vec![0])];
549        assert!(CVineCopula::new(3, vec![tree1, tree2]).is_err());
550    }
551
552    #[test]
553    fn test_dvine_wrong_num_trees() {
554        let c12 = CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
555        let tree1 = vec![PairCopula::new(c12, 0, 1, vec![])];
556        assert!(DVineCopula::new(3, vec![tree1]).is_err());
557    }
558
559    #[test]
560    fn test_dvine_wrong_num_pairs_in_tree() {
561        let clayton = || CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
562        // Tree 1 of a 3D D-vine needs 2 pair-copulas.
563        let tree1 = vec![PairCopula::new(clayton(), 0, 1, vec![])];
564        let tree2 = vec![PairCopula::new(clayton(), 0, 2, vec![1])];
565        assert!(DVineCopula::new(3, vec![tree1, tree2]).is_err());
566    }
567
568    #[test]
569    fn test_dvine_creation() {
570        // Create a simple 3D D-vine
571        let c12 = CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
572        let c23 = CopulaType::Clayton(ClaytonCopula::new(1.5).unwrap());
573        let c13_2 = CopulaType::Clayton(ClaytonCopula::new(1.0).unwrap());
574
575        let tree1 = vec![
576            PairCopula::new(c12, 0, 1, vec![]),
577            PairCopula::new(c23, 1, 2, vec![]),
578        ];
579
580        let tree2 = vec![PairCopula::new(c13_2, 0, 2, vec![1])];
581
582        let dvine = DVineCopula::new(3, vec![tree1, tree2]).unwrap();
583        assert_eq!(dvine.dimension(), 3);
584    }
585
586    fn gaussian_pair(rho: f64) -> PairCopula {
587        let corr = DMatrix::from_row_slice(2, 2, &[1.0, rho, rho, 1.0]);
588        PairCopula::new(
589            CopulaType::Gaussian(GaussianCopula::new(corr).unwrap()),
590            0,
591            0,
592            vec![],
593        )
594    }
595
596    fn student_t_pair(rho: f64, df: f64) -> PairCopula {
597        let corr = DMatrix::from_row_slice(2, 2, &[1.0, rho, rho, 1.0]);
598        PairCopula::new(
599            CopulaType::StudentT(StudentTCopula::new(corr, df).unwrap()),
600            0,
601            0,
602            vec![],
603        )
604    }
605
606    fn clayton_pair(theta: f64) -> PairCopula {
607        PairCopula::new(
608            CopulaType::Clayton(ClaytonCopula::new(theta).unwrap()),
609            0,
610            0,
611            vec![],
612        )
613    }
614
615    /// rho_{ij|S} from rho_{ij|S,k}, rho_{ik|S}, and rho_{jk|S}.
616    fn unpartial(r_ij_given_k: f64, r_ik: f64, r_jk: f64) -> f64 {
617        r_ij_given_k * ((1.0 - r_ik * r_ik) * (1.0 - r_jk * r_jk)).sqrt() + r_ik * r_jk
618    }
619
620    /// rho_{ij|k} from rho_{ij}, rho_{ik}, and rho_{jk}.
621    fn partial(r_ij: f64, r_ik: f64, r_jk: f64) -> f64 {
622        (r_ij - r_ik * r_jk) / ((1.0 - r_ik * r_ik) * (1.0 - r_jk * r_jk)).sqrt()
623    }
624
625    /// Pearson correlation matrix of the normal scores of `samples`.
626    fn normal_score_correlation(samples: &DMatrix<f64>) -> DMatrix<f64> {
627        let normal = Normal::new(0.0, 1.0).unwrap();
628        let (n, d) = samples.shape();
629        let z = DMatrix::from_fn(n, d, |i, j| normal.inverse_cdf(samples[(i, j)]));
630        let centered = DMatrix::from_fn(n, d, |i, j| z[(i, j)] - z.column(j).mean());
631        let cov = centered.transpose() * &centered;
632        DMatrix::from_fn(d, d, |a, b| {
633            cov[(a, b)] / (cov[(a, a)] * cov[(b, b)]).sqrt()
634        })
635    }
636
637    fn assert_correlations(samples: &DMatrix<f64>, expected: &[[f64; 4]; 4], tol: f64) {
638        let actual = normal_score_correlation(samples);
639        for a in 0..4 {
640            for b in 0..4 {
641                assert!(
642                    (actual[(a, b)] - expected[a][b]).abs() < tol,
643                    "corr({a},{b}) = {:.4}, expected {:.4}",
644                    actual[(a, b)],
645                    expected[a][b]
646                );
647            }
648        }
649    }
650
651    fn column_tau(samples: &DMatrix<f64>, a: usize, b: usize) -> f64 {
652        let x: Vec<f64> = samples.column(a).iter().copied().collect();
653        let y: Vec<f64> = samples.column(b).iter().copied().collect();
654        crate::utils::kendall_tau(&x, &y).unwrap()
655    }
656
657    // A vine with Gaussian pair-copulas whose parameters are partial
658    // correlations is a Gaussian copula, so every entry of the implied
659    // correlation matrix is known in closed form. Before the sampling fix,
660    // trees beyond the first had no effect and these checks failed by 0.35
661    // to 0.57.
662    #[test]
663    fn gaussian_cvine_samples_match_implied_correlations() {
664        use rand::{rngs::StdRng, SeedableRng};
665        let (r01, r02, r03) = (0.6, 0.4, -0.3);
666        let (r12_0, r13_0) = (0.5, 0.2);
667        let r23_01 = -0.4;
668        let r12 = unpartial(r12_0, r01, r02);
669        let r13 = unpartial(r13_0, r01, r03);
670        let r23 = unpartial(unpartial(r23_01, r12_0, r13_0), r02, r03);
671        let expected = [
672            [1.0, r01, r02, r03],
673            [r01, 1.0, r12, r13],
674            [r02, r12, 1.0, r23],
675            [r03, r13, r23, 1.0],
676        ];
677
678        let vine = CVineCopula::new(
679            4,
680            vec![
681                vec![gaussian_pair(r01), gaussian_pair(r02), gaussian_pair(r03)],
682                vec![gaussian_pair(r12_0), gaussian_pair(r13_0)],
683                vec![gaussian_pair(r23_01)],
684            ],
685        )
686        .unwrap();
687        let samples = vine.sample(5000, &mut StdRng::seed_from_u64(7)).unwrap();
688        assert_correlations(&samples, &expected, 0.05);
689    }
690
691    #[test]
692    fn gaussian_dvine_samples_match_implied_correlations() {
693        use rand::{rngs::StdRng, SeedableRng};
694        let (r01, r12, r23) = (0.6, 0.5, -0.3);
695        let (r02_1, r13_2) = (0.4, 0.3);
696        let r03_12 = 0.5;
697        let r02 = unpartial(r02_1, r01, r12);
698        let r13 = unpartial(r13_2, r12, r23);
699        let r03_1 = unpartial(r03_12, r02_1, partial(r23, r12, r13));
700        let r03 = unpartial(r03_1, r01, r13);
701        let expected = [
702            [1.0, r01, r02, r03],
703            [r01, 1.0, r12, r13],
704            [r02, r12, 1.0, r23],
705            [r03, r13, r23, 1.0],
706        ];
707
708        let vine = DVineCopula::new(
709            4,
710            vec![
711                vec![gaussian_pair(r01), gaussian_pair(r12), gaussian_pair(r23)],
712                vec![gaussian_pair(r02_1), gaussian_pair(r13_2)],
713                vec![gaussian_pair(r03_12)],
714            ],
715        )
716        .unwrap();
717        let samples = vine.sample(5000, &mut StdRng::seed_from_u64(7)).unwrap();
718        assert_correlations(&samples, &expected, 0.05);
719    }
720
721    // For elliptical copulas, Kendall's tau is (2 / pi) asin(rho).
722    #[test]
723    fn student_t_vine_first_tree_pairs_match_kendall_tau() {
724        use rand::{rngs::StdRng, SeedableRng};
725        let tau_of = |rho: f64| 2.0 / std::f64::consts::PI * rho.asin();
726
727        let dvine = DVineCopula::new(
728            3,
729            vec![
730                vec![student_t_pair(0.7, 3.0), student_t_pair(-0.4, 3.0)],
731                vec![student_t_pair(0.3, 4.0)],
732            ],
733        )
734        .unwrap();
735        let s = dvine.sample(2000, &mut StdRng::seed_from_u64(3)).unwrap();
736        assert!((column_tau(&s, 0, 1) - tau_of(0.7)).abs() < 0.05);
737        assert!((column_tau(&s, 1, 2) - tau_of(-0.4)).abs() < 0.05);
738
739        let cvine = CVineCopula::new(
740            3,
741            vec![
742                vec![student_t_pair(0.5, 5.0), student_t_pair(0.6, 5.0)],
743                vec![student_t_pair(-0.2, 6.0)],
744            ],
745        )
746        .unwrap();
747        let s = cvine.sample(2000, &mut StdRng::seed_from_u64(4)).unwrap();
748        assert!((column_tau(&s, 0, 1) - tau_of(0.5)).abs() < 0.05);
749        assert!((column_tau(&s, 0, 2) - tau_of(0.6)).abs() < 0.05);
750    }
751
752    // Clayton pair-copulas use the numerical h-function. Kendall's tau of a
753    // Clayton copula is theta / (theta + 2).
754    #[test]
755    fn clayton_dvine_matches_first_tree_and_depends_on_second_tree() {
756        use rand::{rngs::StdRng, SeedableRng};
757        let sample_with_tree2 = |theta: f64| {
758            DVineCopula::new(
759                3,
760                vec![
761                    vec![clayton_pair(2.0), clayton_pair(4.0)],
762                    vec![clayton_pair(theta)],
763                ],
764            )
765            .unwrap()
766            .sample(2000, &mut StdRng::seed_from_u64(5))
767            .unwrap()
768        };
769
770        let weak = sample_with_tree2(0.5);
771        let strong = sample_with_tree2(6.0);
772        for s in [&weak, &strong] {
773            assert!((column_tau(s, 0, 1) - 0.5).abs() < 0.05);
774            assert!((column_tau(s, 1, 2) - 2.0 / 3.0).abs() < 0.05);
775        }
776        assert!(column_tau(&strong, 0, 2) - column_tau(&weak, 0, 2) > 0.1);
777    }
778
779    #[test]
780    fn gaussian_closed_form_h_matches_numerical_derivative() {
781        // The bivariate Gaussian CDF is exact, so the numerical derivative is
782        // a valid reference in the interior of the unit square.
783        let pair = gaussian_pair(0.6);
784        for &u in &[0.1, 0.4, 0.8] {
785            for &v in &[0.2, 0.5, 0.9] {
786                let closed = pair.h_function(u, v).unwrap();
787                let numerical = pair.numerical_h(u, v).unwrap();
788                assert!(
789                    (closed - numerical).abs() < 1e-6,
790                    "h({u}|{v}): closed {closed}, numerical {numerical}"
791                );
792            }
793        }
794    }
795
796    #[test]
797    fn h_inverse_round_trips() {
798        let pairs = [
799            gaussian_pair(-0.5),
800            student_t_pair(0.4, 3.0),
801            clayton_pair(2.0),
802        ];
803        for pair in &pairs {
804            for &w in &[0.05, 0.3, 0.7, 0.95] {
805                for &v in &[0.1, 0.5, 0.9] {
806                    let u = pair.h_inv(w, v).unwrap();
807                    let back = pair.h_function(u, v).unwrap();
808                    assert!((back - w).abs() < 1e-6, "h(h_inv({w}|{v})) = {back}");
809                }
810            }
811        }
812    }
813
814    #[test]
815    fn h_function_is_finite_on_the_unit_square_boundary() {
816        let pairs = [
817            gaussian_pair(0.5),
818            student_t_pair(0.5, 4.0),
819            clayton_pair(2.0),
820            PairCopula::new(
821                CopulaType::Gumbel(GumbelCopula::new(2.0).unwrap()),
822                0,
823                0,
824                vec![],
825            ),
826            PairCopula::new(
827                CopulaType::Frank(FrankCopula::new(3.0).unwrap()),
828                0,
829                0,
830                vec![],
831            ),
832            PairCopula::new(CopulaType::Joe(JoeCopula::new(2.0).unwrap()), 0, 0, vec![]),
833            PairCopula::new(CopulaType::AMH(AMHCopula::new(0.5).unwrap()), 0, 0, vec![]),
834        ];
835        let edges = [0.0, 1e-9, 0.5, 1.0 - 1e-9, 1.0];
836        for pair in &pairs {
837            for &u in &edges {
838                for &v in &edges {
839                    let h = pair.h_function(u, v).unwrap();
840                    assert!((0.0..=1.0).contains(&h), "h({u}|{v}) = {h}");
841                }
842            }
843        }
844    }
845
846    #[test]
847    fn vine_rejects_pair_copula_that_is_not_bivariate() {
848        let trivariate = PairCopula::new(
849            CopulaType::Gaussian(GaussianCopula::new_identity(3).unwrap()),
850            0,
851            1,
852            vec![],
853        );
854        assert!(CVineCopula::new(2, vec![vec![trivariate.clone()]]).is_err());
855        assert!(DVineCopula::new(2, vec![vec![trivariate]]).is_err());
856    }
857
858    #[test]
859    fn test_cvine_sample() {
860        let mut rng = rand::rng();
861
862        // Create a simple 2D C-vine (just one copula)
863        let c12 = CopulaType::Clayton(ClaytonCopula::new(2.0).unwrap());
864        let tree1 = vec![PairCopula::new(c12, 0, 1, vec![])];
865
866        let cvine = CVineCopula::new(2, vec![tree1]).unwrap();
867        let samples = cvine.sample(10, &mut rng).unwrap();
868
869        assert_eq!(samples.nrows(), 10);
870        assert_eq!(samples.ncols(), 2);
871
872        // Check all values in [0, 1]
873        for i in 0..10 {
874            for j in 0..2 {
875                assert!(samples[(i, j)] >= 0.0 && samples[(i, j)] <= 1.0);
876            }
877        }
878    }
879}