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 loading < 0.0 || loading > 1.0 {
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 { loadings, dimension })
60    }
61
62    /// Get the implied correlation between dimensions i and j.
63    ///
64    /// ρ_ij = β_i * β_j
65    pub fn correlation(&self, i: usize, j: usize) -> Result<f64> {
66        if i >= self.dimension || j >= self.dimension {
67            return Err(CopulaError::invalid_parameter("index out of bounds"));
68        }
69        Ok(self.loadings[i] * self.loadings[j])
70    }
71
72    /// Get the correlation matrix implied by the factor loadings.
73    pub fn correlation_matrix(&self) -> DMatrix<f64> {
74        let d = self.dimension;
75        let mut corr = DMatrix::<f64>::zeros(d, d);
76
77        for i in 0..d {
78            for j in 0..d {
79                if i == j {
80                    corr[(i, j)] = 1.0;
81                } else {
82                    corr[(i, j)] = self.loadings[i] * self.loadings[j];
83                }
84            }
85        }
86
87        corr
88    }
89
90    /// Standard normal CDF.
91    fn phi(x: f64) -> f64 {
92        use statrs::distribution::{ContinuousCDF, Normal};
93        Normal::new(0.0, 1.0).expect("standard normal parameters are always valid").cdf(x)
94    }
95
96    /// Inverse standard normal CDF.
97    fn phi_inv(p: f64) -> f64 {
98        use statrs::distribution::{ContinuousCDF, Normal};
99        Normal::new(0.0, 1.0).expect("standard normal parameters are always valid").inverse_cdf(p)
100    }
101}
102
103impl Copula for OneFactorGaussianCopula {
104    fn cdf(&self, u: &[f64]) -> Result<f64> {
105        if u.len() != self.dimension {
106            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
107        }
108        crate::error::validate_unit_range(u)?;
109
110        // For one-factor model, use Gaussian quadrature or numerical integration
111        // over the factor Z ~ N(0,1)
112        let n_points = 50;
113        let z_min = -5.0;
114        let z_max = 5.0;
115        let h = (z_max - z_min) / (n_points - 1) as f64;
116
117        let mut integral = 0.0;
118
119        for k in 0..n_points {
120            let z = z_min + k as f64 * h;
121            let weight = if k == 0 || k == n_points - 1 { 0.5 } else { 1.0 };
122
123            // Compute conditional probability given Z=z
124            let mut cond_prob = 1.0;
125            for i in 0..self.dimension {
126                let x_i = Self::phi_inv(u[i]);
127                let loading = self.loadings[i];
128                let idio_std = (1.0 - loading * loading).sqrt();
129
130                // P(X_i <= x_i | Z = z) = Φ((x_i - β_i*z) / sqrt(1-β_i^2))
131                let cond_cdf = Self::phi((x_i - loading * z) / idio_std);
132                cond_prob *= cond_cdf;
133            }
134
135            // Weight by N(0,1) density of Z
136            let phi_z = (-z * z / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt();
137            integral += weight * cond_prob * phi_z;
138        }
139
140        Ok(integral * h)
141    }
142
143    fn pdf(&self, u: &[f64]) -> Result<f64> {
144        if u.len() != self.dimension {
145            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
146        }
147        crate::error::validate_unit_range(u)?;
148
149        // Use numerical differentiation for PDF
150        let h = 1e-6;
151        let mut grad_product = 1.0;
152
153        for i in 0..self.dimension {
154            let mut u_plus = u.to_vec();
155            u_plus[i] += h;
156
157            if u_plus[i] > 1.0 {
158                u_plus[i] = 1.0;
159            }
160
161            let cdf_plus = self.cdf(&u_plus)?;
162            let cdf_base = self.cdf(u)?;
163
164            grad_product *= (cdf_plus - cdf_base) / h;
165        }
166
167        Ok(grad_product.max(0.0))
168    }
169
170    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
171        let normal = Normal::new(0.0, 1.0)
172            .map_err(|_| CopulaError::computation("failed to create Normal(0,1)"))?;
173
174        let mut samples = DMatrix::<f64>::zeros(n, self.dimension);
175
176        for i in 0..n {
177            // Sample common factor
178            let z: f64 = normal.sample(rng);
179
180            // Sample each dimension
181            for j in 0..self.dimension {
182                let loading = self.loadings[j];
183                let idio_std = (1.0 - loading * loading).sqrt();
184
185                // Sample idiosyncratic component
186                let epsilon: f64 = normal.sample(rng);
187
188                // Compute latent normal variable
189                let x = loading * z + idio_std * epsilon;
190
191                // Transform to uniform via standard normal CDF
192                samples[(i, j)] = Self::phi(x);
193            }
194        }
195
196        Ok(samples)
197    }
198
199    fn dimension(&self) -> usize {
200        self.dimension
201    }
202}
203
204/// Multi-factor Gaussian copula.
205///
206/// Generalizes the one-factor model to K common factors:
207/// X_i = Σ_k β_{ik} * Z_k + sqrt(1 - Σ_k β_{ik}^2) * ε_i
208#[derive(Debug, Clone)]
209pub struct MultiFactorGaussianCopula {
210    /// Factor loadings matrix (dimension × num_factors)
211    loadings: DMatrix<f64>,
212    dimension: usize,
213    num_factors: usize,
214}
215
216impl MultiFactorGaussianCopula {
217    /// Create a new multi-factor Gaussian copula.
218    ///
219    /// # Arguments
220    /// * `loadings` - Loading matrix (dimension × num_factors)
221    ///
222    /// # Returns
223    /// A new multi-factor Gaussian copula
224    pub fn new(loadings: DMatrix<f64>) -> Result<Self> {
225        let dimension = loadings.nrows();
226        let num_factors = loadings.ncols();
227
228        if dimension == 0 || num_factors == 0 {
229            return Err(CopulaError::invalid_parameter(
230                "loadings matrix cannot be empty",
231            ));
232        }
233
234        // Check that row sums of squares <= 1
235        for i in 0..dimension {
236            let mut sum_sq = 0.0;
237            for k in 0..num_factors {
238                sum_sq += loadings[(i, k)].powi(2);
239            }
240            if sum_sq > 1.0 + 1e-10 {
241                return Err(CopulaError::invalid_parameter(&format!(
242                    "row {} has sum of squared loadings > 1",
243                    i
244                )));
245            }
246        }
247
248        Ok(Self {
249            loadings,
250            dimension,
251            num_factors,
252        })
253    }
254
255    /// Get the correlation matrix implied by the factor loadings.
256    pub fn correlation_matrix(&self) -> DMatrix<f64> {
257        // Correlation matrix: Σ = Λ Λ^T + Ψ
258        // where Λ is loadings matrix and Ψ is diagonal (idiosyncratic variances)
259        let lambda_lambda_t = &self.loadings * self.loadings.transpose();
260
261        let mut corr = lambda_lambda_t;
262        for i in 0..self.dimension {
263            corr[(i, i)] = 1.0;
264        }
265
266        corr
267    }
268
269    /// Standard normal CDF.
270    fn phi(x: f64) -> f64 {
271        use statrs::distribution::{ContinuousCDF, Normal};
272        Normal::new(0.0, 1.0).expect("standard normal parameters are always valid").cdf(x)
273    }
274
275    /// Inverse standard normal CDF.
276    fn phi_inv(p: f64) -> f64 {
277        use statrs::distribution::{ContinuousCDF, Normal};
278        Normal::new(0.0, 1.0).expect("standard normal parameters are always valid").inverse_cdf(p)
279    }
280}
281
282impl Copula for MultiFactorGaussianCopula {
283    fn cdf(&self, u: &[f64]) -> Result<f64> {
284        if u.len() != self.dimension {
285            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
286        }
287        crate::error::validate_unit_range(u)?;
288
289        // For multi-factor, this becomes computationally expensive
290        // In practice, would use Monte Carlo or specialized numerical methods
291        Err(CopulaError::not_implemented(
292            "Multi-factor CDF requires Monte Carlo integration",
293        ))
294    }
295
296    fn pdf(&self, u: &[f64]) -> Result<f64> {
297        if u.len() != self.dimension {
298            return Err(CopulaError::dimension_mismatch(self.dimension, u.len()));
299        }
300        crate::error::validate_unit_range(u)?;
301
302        Err(CopulaError::not_implemented(
303            "Multi-factor PDF requires Monte Carlo or numerical methods",
304        ))
305    }
306
307    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
308        let normal = Normal::new(0.0, 1.0)
309            .map_err(|_| CopulaError::computation("failed to create Normal(0,1)"))?;
310
311        let mut samples = DMatrix::<f64>::zeros(n, self.dimension);
312
313        for i in 0..n {
314            // Sample common factors
315            let mut factors = DVector::<f64>::zeros(self.num_factors);
316            for k in 0..self.num_factors {
317                factors[k] = normal.sample(rng);
318            }
319
320            // Sample each dimension
321            for j in 0..self.dimension {
322                // Compute factor contribution
323                let mut factor_contribution = 0.0;
324                let mut sum_sq_loadings = 0.0;
325
326                for k in 0..self.num_factors {
327                    let loading = self.loadings[(j, k)];
328                    factor_contribution += loading * factors[k];
329                    sum_sq_loadings += loading * loading;
330                }
331
332                // Compute idiosyncratic standard deviation
333                let idio_std = (1.0 - sum_sq_loadings).max(0.0).sqrt();
334
335                // Sample idiosyncratic component
336                let epsilon: f64 = normal.sample(rng);
337
338                // Compute latent normal variable
339                let x = factor_contribution + idio_std * epsilon;
340
341                // Transform to uniform
342                samples[(i, j)] = Self::phi(x);
343            }
344        }
345
346        Ok(samples)
347    }
348
349    fn dimension(&self) -> usize {
350        self.dimension
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_one_factor_new() {
360        let loadings = vec![0.5, 0.6, 0.7];
361        let cop = OneFactorGaussianCopula::new(loadings).unwrap();
362        assert_eq!(cop.dimension(), 3);
363    }
364
365    #[test]
366    fn test_one_factor_invalid_loading() {
367        let loadings = vec![0.5, 1.5]; // 1.5 > 1.0
368        assert!(OneFactorGaussianCopula::new(loadings).is_err());
369    }
370
371    #[test]
372    fn test_one_factor_correlation() {
373        let loadings = vec![0.6, 0.8];
374        let cop = OneFactorGaussianCopula::new(loadings).unwrap();
375        let rho = cop.correlation(0, 1).unwrap();
376        assert!((rho - 0.48).abs() < 1e-10); // 0.6 * 0.8 = 0.48
377    }
378
379    #[test]
380    fn test_one_factor_sample() {
381        use rand::thread_rng;
382        let mut rng = thread_rng();
383
384        let loadings = vec![0.7, 0.7, 0.7];
385        let cop = OneFactorGaussianCopula::new(loadings).unwrap();
386        let samples = cop.sample(100, &mut rng).unwrap();
387
388        assert_eq!(samples.nrows(), 100);
389        assert_eq!(samples.ncols(), 3);
390
391        // Check all values in [0, 1]
392        for i in 0..100 {
393            for j in 0..3 {
394                assert!(samples[(i, j)] >= 0.0 && samples[(i, j)] <= 1.0);
395            }
396        }
397    }
398
399    #[test]
400    fn test_multi_factor_new() {
401        let loadings = DMatrix::from_row_slice(3, 2, &[
402            0.5, 0.3,  // dim 1
403            0.6, 0.4,  // dim 2
404            0.7, 0.2,  // dim 3
405        ]);
406        let cop = MultiFactorGaussianCopula::new(loadings).unwrap();
407        assert_eq!(cop.dimension(), 3);
408    }
409
410    #[test]
411    fn test_multi_factor_sample() {
412        use rand::thread_rng;
413        let mut rng = thread_rng();
414
415        let loadings = DMatrix::from_row_slice(2, 2, &[
416            0.6, 0.3,
417            0.5, 0.4,
418        ]);
419        let cop = MultiFactorGaussianCopula::new(loadings).unwrap();
420        let samples = cop.sample(50, &mut rng).unwrap();
421
422        assert_eq!(samples.nrows(), 50);
423        assert_eq!(samples.ncols(), 2);
424
425        // Check all values in [0, 1]
426        for i in 0..50 {
427            for j in 0..2 {
428                assert!(samples[(i, j)] >= 0.0 && samples[(i, j)] <= 1.0);
429            }
430        }
431    }
432}