1use crate::{Copula, CopulaError, Result};
17use nalgebra::{DMatrix, DVector};
18use rand::Rng;
19use rand_distr::{Distribution, Normal};
20
21#[derive(Debug, Clone)]
30pub struct OneFactorGaussianCopula {
31 loadings: Vec<f64>,
33 dimension: usize,
34}
35
36impl OneFactorGaussianCopula {
37 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 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 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 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 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 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 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 let cond_cdf = Self::phi((x_i - loading * z) / idio_std);
142 cond_prob *= cond_cdf;
143 }
144
145 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 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 let z: f64 = normal.sample(rng);
189
190 for j in 0..self.dimension {
192 let loading = self.loadings[j];
193 let idio_std = (1.0 - loading * loading).sqrt();
194
195 let epsilon: f64 = normal.sample(rng);
197
198 let x = loading * z + idio_std * epsilon;
200
201 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#[derive(Debug, Clone)]
219pub struct MultiFactorGaussianCopula {
220 loadings: DMatrix<f64>,
222 dimension: usize,
223 num_factors: usize,
224}
225
226impl MultiFactorGaussianCopula {
227 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 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 pub fn correlation_matrix(&self) -> DMatrix<f64> {
271 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 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 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 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 for j in 0..self.dimension {
332 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 let idio_std = (1.0 - sum_sq_loadings).max(0.0).sqrt();
344
345 let epsilon: f64 = normal.sample(rng);
347
348 let x = factor_contribution + idio_std * epsilon;
350
351 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]; 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); }
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 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 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, 0.6, 0.4, 0.7, 0.2, ]);
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 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 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}