Skip to main content

copula_core/factor/
mod.rs

1//! Factor copulas module.
2//!
3//! Factor copulas model dependence through latent common factors.
4//! They are particularly useful in high-dimensional settings where
5//! the dependence structure can be explained by a smaller number of factors.
6//!
7//! ## Common Applications
8//! - Portfolio credit risk modeling
9//! - Multivariate financial modeling
10//! - Dimension reduction in dependence modeling
11//!
12//! ## Bibliography
13//! - Oh, D. H., & Patton, A. J. (2017). Modeling dependence in high dimensions with factor copulas.
14//! - Joe, H. (2014). *Dependence Modeling with Copulas*. CRC Press.
15
16use crate::{Copula, CopulaError, Result};
17use nalgebra::{DMatrix, DVector};
18use rand::Rng;
19use rand_distr::{Distribution, Normal};
20
21/// One-factor Gaussian copula.
22///
23/// Models dependence through a single common factor Z and idiosyncratic terms.
24/// For each variable i: X_i = β_i * Z + sqrt(1 - β_i^2) * ε_i
25/// where Z, ε_i ~ N(0,1) are independent.
26///
27/// ## Bibliography
28/// - Li, D. X. (2000). On default correlation: A copula function approach.
29#[derive(Debug, Clone)]
30pub struct OneFactorGaussianCopula {
31    /// Factor loadings β_i ∈ [0, 1] for each dimension
32    loadings: Vec<f64>,
33    dimension: usize,
34}
35
36impl OneFactorGaussianCopula {
37    /// Create a new one-factor Gaussian copula.
38    ///
39    /// # Arguments
40    /// * `loadings` - Factor loadings for each dimension (must be in [0, 1])
41    ///
42    /// # Returns
43    /// A new one-factor Gaussian copula
44    pub fn new(loadings: Vec<f64>) -> Result<Self> {
45        if loadings.is_empty() {
46            return Err(CopulaError::invalid_parameter("loadings cannot be empty"));
47        }
48
49        for (i, &loading) in loadings.iter().enumerate() {
50            if !(0.0..=1.0).contains(&loading) {
51                return Err(CopulaError::invalid_parameter(format!(
52                    "loading[{}] = {} must be in [0, 1]",
53                    i, loading
54                )));
55            }
56        }
57
58        let dimension = loadings.len();
59        Ok(Self {
60            loadings,
61            dimension,
62        })
63    }
64
65    /// Get the implied correlation between dimensions i and j.
66    ///
67    /// ρ_ij = β_i * β_j
68    pub fn correlation(&self, i: usize, j: usize) -> Result<f64> {
69        if i >= self.dimension || j >= self.dimension {
70            return Err(CopulaError::invalid_parameter("index out of bounds"));
71        }
72        Ok(self.loadings[i] * self.loadings[j])
73    }
74
75    /// Get the correlation matrix implied by the factor loadings.
76    pub fn correlation_matrix(&self) -> DMatrix<f64> {
77        let d = self.dimension;
78        let mut corr = DMatrix::<f64>::zeros(d, d);
79
80        for i in 0..d {
81            for j in 0..d {
82                if i == j {
83                    corr[(i, j)] = 1.0;
84                } else {
85                    corr[(i, j)] = self.loadings[i] * self.loadings[j];
86                }
87            }
88        }
89
90        corr
91    }
92
93    /// Standard normal CDF.
94    fn phi(x: f64) -> f64 {
95        use statrs::distribution::{ContinuousCDF, Normal};
96        Normal::new(0.0, 1.0)
97            .expect("standard normal parameters are always valid")
98            .cdf(x)
99    }
100
101    /// Inverse standard normal CDF.
102    fn phi_inv(p: f64) -> f64 {
103        use statrs::distribution::{ContinuousCDF, Normal};
104        Normal::new(0.0, 1.0)
105            .expect("standard normal parameters are always valid")
106            .inverse_cdf(p)
107    }
108}
109
110impl Copula for OneFactorGaussianCopula {
111    fn cdf(&self, u: &[f64]) -> Result<f64> {
112        if u.len() != self.dimension {
113            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
114        }
115        crate::error::validate_unit_range(u)?;
116
117        // For one-factor model, use Gaussian quadrature or numerical integration
118        // over the factor Z ~ N(0,1)
119        let n_points = 50;
120        let z_min = -5.0;
121        let z_max = 5.0;
122        let h = (z_max - z_min) / (n_points - 1) as f64;
123
124        let mut integral = 0.0;
125
126        for k in 0..n_points {
127            let z = z_min + k as f64 * h;
128            let weight = if k == 0 || k == n_points - 1 {
129                0.5
130            } else {
131                1.0
132            };
133
134            // Compute conditional probability given Z=z
135            let mut cond_prob = 1.0;
136            for (&u_i, &loading) in u.iter().zip(&self.loadings) {
137                let x_i = Self::phi_inv(u_i);
138                let idio_std = (1.0 - loading * loading).sqrt();
139
140                // P(X_i <= x_i | Z = z) = Φ((x_i - β_i*z) / sqrt(1-β_i^2))
141                let cond_cdf = Self::phi((x_i - loading * z) / idio_std);
142                cond_prob *= cond_cdf;
143            }
144
145            // Weight by N(0,1) density of Z
146            let phi_z = (-z * z / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt();
147            integral += weight * cond_prob * phi_z;
148        }
149
150        Ok(integral * h)
151    }
152
153    fn pdf(&self, u: &[f64]) -> Result<f64> {
154        if u.len() != self.dimension {
155            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
156        }
157        crate::error::validate_unit_range(u)?;
158
159        // Use numerical differentiation for PDF
160        let h = 1e-6;
161        let mut grad_product = 1.0;
162
163        for i in 0..self.dimension {
164            let mut u_plus = u.to_vec();
165            u_plus[i] += h;
166
167            if u_plus[i] > 1.0 {
168                u_plus[i] = 1.0;
169            }
170
171            let cdf_plus = self.cdf(&u_plus)?;
172            let cdf_base = self.cdf(u)?;
173
174            grad_product *= (cdf_plus - cdf_base) / h;
175        }
176
177        Ok(grad_product.max(0.0))
178    }
179
180    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
181        let normal = Normal::new(0.0, 1.0)
182            .map_err(|_| CopulaError::computation("failed to create Normal(0,1)"))?;
183
184        let mut samples = DMatrix::<f64>::zeros(n, self.dimension);
185
186        for i in 0..n {
187            // Sample common factor
188            let z: f64 = normal.sample(rng);
189
190            // Sample each dimension
191            for j in 0..self.dimension {
192                let loading = self.loadings[j];
193                let idio_std = (1.0 - loading * loading).sqrt();
194
195                // Sample idiosyncratic component
196                let epsilon: f64 = normal.sample(rng);
197
198                // Compute latent normal variable
199                let x = loading * z + idio_std * epsilon;
200
201                // Transform to uniform via standard normal CDF
202                samples[(i, j)] = Self::phi(x);
203            }
204        }
205
206        Ok(samples)
207    }
208
209    fn dimension(&self) -> usize {
210        self.dimension
211    }
212}
213
214/// Multi-factor Gaussian copula.
215///
216/// Generalizes the one-factor model to K common factors:
217/// X_i = Σ_k β_{ik} * Z_k + sqrt(1 - Σ_k β_{ik}^2) * ε_i
218#[derive(Debug, Clone)]
219pub struct MultiFactorGaussianCopula {
220    /// Factor loadings matrix (dimension × num_factors)
221    loadings: DMatrix<f64>,
222    dimension: usize,
223    num_factors: usize,
224}
225
226impl MultiFactorGaussianCopula {
227    /// Create a new multi-factor Gaussian copula.
228    ///
229    /// # Arguments
230    /// * `loadings` - Loading matrix (dimension × num_factors)
231    ///
232    /// # Returns
233    /// A new multi-factor Gaussian copula
234    pub fn new(loadings: DMatrix<f64>) -> Result<Self> {
235        let dimension = loadings.nrows();
236        let num_factors = loadings.ncols();
237
238        if dimension == 0 || num_factors == 0 {
239            return Err(CopulaError::invalid_parameter(
240                "loadings matrix cannot be empty",
241            ));
242        }
243
244        if loadings.iter().any(|x| !x.is_finite()) {
245            return Err(CopulaError::invalid_parameter("loadings must be finite"));
246        }
247
248        // Check that row sums of squares <= 1
249        for i in 0..dimension {
250            let mut sum_sq = 0.0;
251            for k in 0..num_factors {
252                sum_sq += loadings[(i, k)].powi(2);
253            }
254            if sum_sq > 1.0 + 1e-10 {
255                return Err(CopulaError::invalid_parameter(format!(
256                    "row {} has sum of squared loadings > 1",
257                    i
258                )));
259            }
260        }
261
262        Ok(Self {
263            loadings,
264            dimension,
265            num_factors,
266        })
267    }
268
269    /// Get the correlation matrix implied by the factor loadings.
270    pub fn correlation_matrix(&self) -> DMatrix<f64> {
271        // Correlation matrix: Σ = Λ Λ^T + Ψ
272        // where Λ is loadings matrix and Ψ is diagonal (idiosyncratic variances)
273        let lambda_lambda_t = &self.loadings * self.loadings.transpose();
274
275        let mut corr = lambda_lambda_t;
276        for i in 0..self.dimension {
277            corr[(i, i)] = 1.0;
278        }
279
280        corr
281    }
282
283    /// Standard normal CDF.
284    fn phi(x: f64) -> f64 {
285        use statrs::distribution::{ContinuousCDF, Normal};
286        Normal::new(0.0, 1.0)
287            .expect("standard normal parameters are always valid")
288            .cdf(x)
289    }
290}
291
292impl Copula for MultiFactorGaussianCopula {
293    fn cdf(&self, u: &[f64]) -> Result<f64> {
294        if u.len() != self.dimension {
295            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
296        }
297        crate::error::validate_unit_range(u)?;
298
299        // For multi-factor, this becomes computationally expensive
300        // In practice, would use Monte Carlo or specialized numerical methods
301        Err(CopulaError::not_implemented(
302            "Multi-factor CDF requires Monte Carlo integration",
303        ))
304    }
305
306    fn pdf(&self, u: &[f64]) -> Result<f64> {
307        if u.len() != self.dimension {
308            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
309        }
310        crate::error::validate_unit_range(u)?;
311
312        Err(CopulaError::not_implemented(
313            "Multi-factor PDF requires Monte Carlo or numerical methods",
314        ))
315    }
316
317    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
318        let normal = Normal::new(0.0, 1.0)
319            .map_err(|_| CopulaError::computation("failed to create Normal(0,1)"))?;
320
321        let mut samples = DMatrix::<f64>::zeros(n, self.dimension);
322
323        for i in 0..n {
324            // Sample common factors
325            let mut factors = DVector::<f64>::zeros(self.num_factors);
326            for k in 0..self.num_factors {
327                factors[k] = normal.sample(rng);
328            }
329
330            // Sample each dimension
331            for j in 0..self.dimension {
332                // Compute factor contribution
333                let mut factor_contribution = 0.0;
334                let mut sum_sq_loadings = 0.0;
335
336                for k in 0..self.num_factors {
337                    let loading = self.loadings[(j, k)];
338                    factor_contribution += loading * factors[k];
339                    sum_sq_loadings += loading * loading;
340                }
341
342                // Compute idiosyncratic standard deviation
343                let idio_std = (1.0 - sum_sq_loadings).max(0.0).sqrt();
344
345                // Sample idiosyncratic component
346                let epsilon: f64 = normal.sample(rng);
347
348                // Compute latent normal variable
349                let x = factor_contribution + idio_std * epsilon;
350
351                // Transform to uniform
352                samples[(i, j)] = Self::phi(x);
353            }
354        }
355
356        Ok(samples)
357    }
358
359    fn dimension(&self) -> usize {
360        self.dimension
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_one_factor_new() {
370        let loadings = vec![0.5, 0.6, 0.7];
371        let cop = OneFactorGaussianCopula::new(loadings).unwrap();
372        assert_eq!(cop.dimension(), 3);
373    }
374
375    #[test]
376    fn test_one_factor_invalid_loading() {
377        let loadings = vec![0.5, 1.5]; // 1.5 > 1.0
378        assert!(OneFactorGaussianCopula::new(loadings).is_err());
379    }
380
381    #[test]
382    fn test_one_factor_correlation() {
383        let loadings = vec![0.6, 0.8];
384        let cop = OneFactorGaussianCopula::new(loadings).unwrap();
385        let rho = cop.correlation(0, 1).unwrap();
386        assert!((rho - 0.48).abs() < 1e-10); // 0.6 * 0.8 = 0.48
387    }
388
389    #[test]
390    fn test_one_factor_cdf_zero_loadings_is_independence() {
391        let cop = OneFactorGaussianCopula::new(vec![0.0, 0.0]).unwrap();
392        let c = cop.cdf(&[0.3, 0.6]).unwrap();
393        assert!((c - 0.18).abs() < 1e-4, "C(0.3, 0.6) = {c}");
394    }
395
396    #[test]
397    fn test_one_factor_cdf_matches_bivariate_normal_at_median() {
398        // Sheppard's formula: P(X <= 0, Y <= 0) = 1/4 + asin(rho) / (2 pi),
399        // with rho = beta_1 * beta_2 for a one-factor model.
400        let cop = OneFactorGaussianCopula::new(vec![0.6, 0.8]).unwrap();
401        let rho: f64 = 0.6 * 0.8;
402        let expected = 0.25 + rho.asin() / (2.0 * std::f64::consts::PI);
403        let c = cop.cdf(&[0.5, 0.5]).unwrap();
404        assert!(
405            (c - expected).abs() < 1e-3,
406            "C(0.5, 0.5) = {c}, expected {expected}"
407        );
408    }
409
410    #[test]
411    fn test_one_factor_sample() {
412        let mut rng = rand::rng();
413
414        let loadings = vec![0.7, 0.7, 0.7];
415        let cop = OneFactorGaussianCopula::new(loadings).unwrap();
416        let samples = cop.sample(100, &mut rng).unwrap();
417
418        assert_eq!(samples.nrows(), 100);
419        assert_eq!(samples.ncols(), 3);
420
421        // Check all values in [0, 1]
422        for i in 0..100 {
423            for j in 0..3 {
424                assert!(samples[(i, j)] >= 0.0 && samples[(i, j)] <= 1.0);
425            }
426        }
427    }
428
429    #[test]
430    fn test_multi_factor_new() {
431        #[rustfmt::skip]
432        let loadings = DMatrix::from_row_slice(3, 2, &[
433            0.5, 0.3,  // dim 1
434            0.6, 0.4,  // dim 2
435            0.7, 0.2,  // dim 3
436        ]);
437        let cop = MultiFactorGaussianCopula::new(loadings).unwrap();
438        assert_eq!(cop.dimension(), 3);
439    }
440
441    #[test]
442    fn test_multi_factor_rejects_row_variance_above_one() {
443        // 0.8^2 + 0.7^2 = 1.13 > 1
444        let loadings = DMatrix::from_row_slice(2, 2, &[0.8, 0.7, 0.5, 0.4]);
445        assert!(MultiFactorGaussianCopula::new(loadings).is_err());
446    }
447
448    #[test]
449    fn test_multi_factor_sample() {
450        let mut rng = rand::rng();
451
452        let loadings = DMatrix::from_row_slice(2, 2, &[0.6, 0.3, 0.5, 0.4]);
453        let cop = MultiFactorGaussianCopula::new(loadings).unwrap();
454        let samples = cop.sample(50, &mut rng).unwrap();
455
456        assert_eq!(samples.nrows(), 50);
457        assert_eq!(samples.ncols(), 2);
458
459        // Check all values in [0, 1]
460        for i in 0..50 {
461            for j in 0..2 {
462                assert!(samples[(i, j)] >= 0.0 && samples[(i, j)] <= 1.0);
463            }
464        }
465    }
466}