Skip to main content

copula_core/elliptical/
student_t.rs

1//! Student's t copula implementation.
2//!
3//! ## Bibliography
4//! - Demarta, S., & McNeil, A. J. (2005). The t copula and related copulas.
5//!   *International Statistical Review*, 73(1), 111-129.
6//! - McNeil, A. J., Frey, R., & Embrechts, P. (2015). *Quantitative Risk
7//!   Management: Concepts, Techniques and Tools*. Princeton University Press.
8//! - Nelsen, R. B. (2006). *An Introduction to Copulas*. Springer.
9
10#[cfg(feature = "estimation")]
11use crate::traits::FittableCopula;
12#[cfg(feature = "estimation")]
13use crate::utils::multivariate_kendall_tau;
14use crate::{utils::validate_correlation_matrix, Copula, CopulaError, Result};
15use nalgebra::{DMatrix, DVector};
16use rand::Rng;
17use rand_distr::{ChiSquared, Distribution, StandardNormal};
18use statrs::distribution::{Continuous, ContinuousCDF, StudentsT};
19
20/// Student's t copula placeholder
21#[derive(Debug, Clone)]
22pub struct StudentTCopula {
23    correlation: DMatrix<f64>,
24    df: f64,
25}
26
27validated_serde!("StudentTCopula", StudentTCopula { correlation: DMatrix<f64>, df: f64 } => StudentTCopula::new(correlation, df));
28
29impl StudentTCopula {
30    /// Create a Student's t copula from a correlation matrix and degrees of freedom.
31    pub fn new(correlation: DMatrix<f64>, df: f64) -> Result<Self> {
32        if df <= 0.0 || !df.is_finite() {
33            return Err(CopulaError::invalid_parameter("df must be positive"));
34        }
35        validate_correlation_matrix(&correlation)?;
36        Ok(Self { correlation, df })
37    }
38
39    /// Identity correlation matrix with given dimension and degrees of freedom.
40    pub fn new_identity(dim: usize, df: f64) -> Result<Self> {
41        if dim < 2 {
42            return Err(CopulaError::invalid_parameter(
43                "Copula dimension must be at least 2",
44            ));
45        }
46        Self::new(DMatrix::identity(dim, dim), df)
47    }
48
49    /// The correlation matrix.
50    pub fn correlation(&self) -> &DMatrix<f64> {
51        &self.correlation
52    }
53
54    /// The degrees of freedom.
55    pub fn df(&self) -> f64 {
56        self.df
57    }
58
59    fn dim(&self) -> usize {
60        self.correlation.ncols()
61    }
62}
63
64impl Copula for StudentTCopula {
65    fn cdf(&self, u: &[f64]) -> Result<f64> {
66        if u.len() != self.dim() {
67            return Err(CopulaError::dimension_mismatch(self.dim(), u.len()));
68        }
69        crate::error::validate_unit_range(u)?;
70        // Quantile transforms are infinite on the boundary; the copula axioms
71        // give the exact value there.
72        if let Some(value) = crate::utils::copula_boundary_value(u) {
73            return Ok(value);
74        }
75        // Quantiles of univariate Student's t distribution
76        let t = StudentsT::new(0.0, 1.0, self.df)
77            .map_err(|_| CopulaError::computation("failed to create Student's t distribution"))?;
78        let quantiles: DVector<f64> =
79            DVector::from_iterator(self.dim(), u.iter().map(|&ui| t.inverse_cdf(ui)));
80
81        // Monte Carlo approximation for any dimension
82        let chol = self
83            .correlation
84            .clone()
85            .cholesky()
86            .ok_or_else(|| CopulaError::invalid_parameter("correlation not PD"))?;
87        let mut rng = rand::rng();
88        let chi = ChiSquared::new(self.df)
89            .map_err(|_| CopulaError::computation("failed to create Chi-squared distribution"))?;
90        let normal = StandardNormal;
91
92        let mut count = 0usize;
93        let n_samples = 10_000usize;
94
95        for _ in 0..n_samples {
96            let dim = self.dim();
97            let z = DVector::from_iterator(dim, (0..dim).map(|_| normal.sample(&mut rng)));
98            let norm = chol.l() * z;
99            let w = chi.sample(&mut rng);
100            let scale = (self.df / w).sqrt();
101            let t_sample = norm * scale;
102
103            if (0..self.dim()).all(|i| t_sample[i] <= quantiles[i]) {
104                count += 1;
105            }
106        }
107
108        Ok(crate::utils::clamp_to_frechet_bounds(
109            u,
110            count as f64 / n_samples as f64,
111        ))
112    }
113
114    fn pdf(&self, u: &[f64]) -> Result<f64> {
115        if u.len() != self.dim() {
116            return Err(CopulaError::dimension_mismatch(self.dim(), u.len()));
117        }
118        crate::error::validate_unit_range(u)?;
119
120        if self.dim() == 1 {
121            return Ok(1.0);
122        }
123
124        let t = StudentsT::new(0.0, 1.0, self.df)
125            .map_err(|_| CopulaError::computation("failed to create Student's t distribution"))?;
126        let x = DVector::from_iterator(self.dim(), u.iter().map(|&ui| t.inverse_cdf(ui)));
127
128        let inv = self
129            .correlation
130            .clone()
131            .try_inverse()
132            .ok_or_else(|| CopulaError::matrix_error("inverse", "singular"))?;
133        let det = self.correlation.determinant();
134        let quad = (inv.clone() * &x).dot(&x);
135
136        use statrs::function::gamma::ln_gamma;
137        use std::f64::consts::PI;
138
139        let d = self.dim() as f64;
140        let log_num = ln_gamma((self.df + d) / 2.0);
141        let log_denom =
142            ln_gamma(self.df / 2.0) + (d / 2.0) * (self.df.ln() + PI.ln()) + 0.5 * det.ln();
143        let log_kernel = -((self.df + d) / 2.0) * ((1.0 + quad / self.df).ln());
144        let log_joint = log_num - log_denom + log_kernel;
145
146        let sum_log_marginals: f64 = x.iter().map(|&xi| t.pdf(xi).ln()).sum();
147
148        Ok((log_joint - sum_log_marginals).exp())
149    }
150
151    fn sample<R: Rng + ?Sized>(&self, n: usize, rng: &mut R) -> Result<DMatrix<f64>> {
152        let dim = self.dim();
153        let chol = self
154            .correlation
155            .clone()
156            .cholesky()
157            .ok_or_else(|| CopulaError::invalid_parameter("correlation not PD"))?;
158        let normal = StandardNormal;
159        let chi = ChiSquared::new(self.df)
160            .map_err(|_| CopulaError::computation("failed to create Chi-squared distribution"))?;
161        let t_dist = StudentsT::new(0.0, 1.0, self.df)
162            .map_err(|_| CopulaError::computation("failed to create Student's t distribution"))?;
163        let mut samples = DMatrix::<f64>::zeros(n, dim);
164
165        for i in 0..n {
166            let z = DVector::from_iterator(dim, (0..dim).map(|_| normal.sample(rng)));
167            let y = chol.l() * z;
168            let w = chi.sample(rng);
169            let scale = (self.df / w).sqrt();
170            let t_sample = y * scale;
171            for j in 0..dim {
172                samples[(i, j)] = t_dist.cdf(t_sample[j]);
173            }
174        }
175
176        Ok(samples)
177    }
178
179    fn dimension(&self) -> usize {
180        self.dim()
181    }
182}
183
184#[cfg(feature = "estimation")]
185impl FittableCopula for StudentTCopula {
186    type Parameters = (DMatrix<f64>, f64);
187
188    fn fit(&mut self, pseudo_obs: &DMatrix<f64>) -> Result<Self::Parameters> {
189        crate::utils::validate_pseudo_observations(pseudo_obs)?;
190        let n = pseudo_obs.nrows();
191        let dim = pseudo_obs.ncols();
192        let t_dist = StudentsT::new(0.0, 1.0, self.df)
193            .map_err(|_| CopulaError::computation("failed to create Student's t distribution"))?;
194        let mut z = DMatrix::<f64>::zeros(n, dim);
195        for i in 0..n {
196            for j in 0..dim {
197                z[(i, j)] = t_dist.inverse_cdf(pseudo_obs[(i, j)]);
198            }
199        }
200
201        let mut corr = DMatrix::<f64>::identity(dim, dim);
202        for i in 0..dim {
203            for j in i + 1..dim {
204                let mut sum_i = 0.0;
205                let mut sum_j = 0.0;
206                for k in 0..n {
207                    sum_i += z[(k, i)];
208                    sum_j += z[(k, j)];
209                }
210                let mean_i = sum_i / n as f64;
211                let mean_j = sum_j / n as f64;
212                let mut cov = 0.0;
213                let mut var_i = 0.0;
214                let mut var_j = 0.0;
215                for k in 0..n {
216                    let xi = z[(k, i)] - mean_i;
217                    let xj = z[(k, j)] - mean_j;
218                    cov += xi * xj;
219                    var_i += xi * xi;
220                    var_j += xj * xj;
221                }
222                cov /= n as f64;
223                var_i /= n as f64;
224                var_j /= n as f64;
225                let r = cov / (var_i.sqrt() * var_j.sqrt());
226                corr[(i, j)] = r;
227                corr[(j, i)] = r;
228            }
229        }
230        validate_correlation_matrix(&corr)?;
231        self.correlation = corr.clone();
232        Ok((corr, self.df))
233    }
234
235    fn log_likelihood(&self, pseudo_obs: &DMatrix<f64>) -> Result<f64> {
236        crate::utils::validate_pseudo_observations(pseudo_obs)?;
237        if pseudo_obs.ncols() != self.dim() {
238            return Err(CopulaError::dimension_mismatch(
239                self.dim(),
240                pseudo_obs.ncols(),
241            ));
242        }
243        let n = pseudo_obs.nrows();
244        let t = StudentsT::new(0.0, 1.0, self.df)
245            .map_err(|_| CopulaError::computation("failed to create Student's t distribution"))?;
246        let mut ll = 0.0;
247        let inv = self
248            .correlation
249            .clone()
250            .try_inverse()
251            .ok_or_else(|| CopulaError::matrix_error("inverse", "singular"))?;
252        let det = self.correlation.determinant();
253        for i in 0..n {
254            let x = DVector::from_iterator(
255                self.dim(),
256                (0..self.dim()).map(|j| t.inverse_cdf(pseudo_obs[(i, j)])),
257            );
258            let quad = (inv.clone() * &x).dot(&x);
259            use statrs::function::gamma::ln_gamma;
260            use std::f64::consts::PI;
261            let d = self.dim() as f64;
262            let log_num = ln_gamma((self.df + d) / 2.0);
263            let log_denom =
264                ln_gamma(self.df / 2.0) + (d / 2.0) * (self.df.ln() + PI.ln()) + 0.5 * det.ln();
265            let log_kernel = -((self.df + d) / 2.0) * ((1.0 + quad / self.df).ln());
266            let log_joint = log_num - log_denom + log_kernel;
267            let sum_log_marginals: f64 = x.iter().map(|&xi| t.pdf(xi).ln()).sum();
268            ll += log_joint - sum_log_marginals;
269        }
270        Ok(ll)
271    }
272
273    fn fit_moments(&mut self, pseudo_obs: &DMatrix<f64>) -> Result<Self::Parameters> {
274        let tau = multivariate_kendall_tau(pseudo_obs)?;
275        let dim = tau.ncols();
276        let mut corr = DMatrix::<f64>::identity(dim, dim);
277        for i in 0..dim {
278            for j in (i + 1)..dim {
279                let val = (std::f64::consts::PI * 0.5 * tau[(i, j)]).sin();
280                corr[(i, j)] = val;
281                corr[(j, i)] = val;
282            }
283        }
284        validate_correlation_matrix(&corr)?;
285        self.correlation = corr.clone();
286        Ok((corr, self.df))
287    }
288
289    fn parameters(&self) -> Self::Parameters {
290        (self.correlation.clone(), self.df)
291    }
292
293    fn set_parameters(&mut self, params: Self::Parameters) -> Result<()> {
294        validate_correlation_matrix(&params.0)?;
295        self.correlation = params.0;
296        self.df = params.1;
297        Ok(())
298    }
299}
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn dimension_is_preserved() {
306        let cop = StudentTCopula::new_identity(4, 3.0).unwrap();
307        assert_eq!(cop.dimension(), 4);
308    }
309
310    #[test]
311    fn cdf_identity_is_product() {
312        let cop = StudentTCopula::new_identity(2, 5.0).unwrap();
313        let val = cop.cdf(&[0.1, 0.2]).unwrap();
314        assert!((val - 0.1 * 0.2).abs() < 0.02); // Monte Carlo approx
315    }
316
317    #[test]
318    fn cdf_with_correlation() {
319        let corr = DMatrix::from_row_slice(2, 2, &[1.0, 0.4, 0.4, 1.0]);
320        let cop = StudentTCopula::new(corr.clone(), 4.0).unwrap();
321        let _t = StudentsT::new(0.0, 1.0, 4.0).unwrap();
322        // Just ensure the method runs and returns probability
323        let res = cop.cdf(&[0.3, 0.6]).unwrap();
324        assert!(res > 0.0 && res < 1.0);
325    }
326
327    #[test]
328    fn cdf_higher_dimension_identity() {
329        let cop = StudentTCopula::new_identity(3, 3.0).unwrap();
330        let val = cop.cdf(&[0.2, 0.3, 0.4]).unwrap();
331        assert!(val > 0.0 && val < 1.0);
332    }
333
334    #[test]
335    fn pdf_identity_is_one() {
336        let cop = StudentTCopula::new_identity(2, 4.0).unwrap();
337        let pdf = cop.pdf(&[0.6, 0.2]).unwrap();
338        assert!(pdf > 0.0);
339    }
340
341    #[test]
342    fn sample_returns_valid_matrix() {
343        let mut rng = rand::rng();
344        let cop = StudentTCopula::new_identity(2, 5.0).unwrap();
345        let samples = cop.sample(5, &mut rng).unwrap();
346        assert_eq!(samples.nrows(), 5);
347        assert_eq!(samples.ncols(), 2);
348        for i in 0..5 {
349            for j in 0..2 {
350                assert!(samples[(i, j)] > 0.0 && samples[(i, j)] < 1.0);
351            }
352        }
353    }
354}