1#[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#[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 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 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 pub fn correlation(&self) -> &DMatrix<f64> {
51 &self.correlation
52 }
53
54 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 if let Some(value) = crate::utils::copula_boundary_value(u) {
73 return Ok(value);
74 }
75 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 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(¶ms.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); }
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 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}