Skip to main content

copula_core/estimation/
mod.rs

1//! Parameter estimation methods for copulas.
2//!
3//! This module provides various methods for estimating copula parameters from data:
4//! - Maximum Likelihood Estimation (MLE)
5//! - Inference Functions for Margins (IFM)
6//! - Canonical Maximum Likelihood (CML)
7//! - Method of Moments
8//!
9//! ## Overview
10//!
11//! ### Maximum Likelihood (MLE)
12//! Estimates both marginal and copula parameters jointly by maximizing:
13//! L(θ) = Σ log c(F₁(x₁|θ₁), ..., Fₐ(xₐ|θₐ)|θ_c)
14//!
15//! ### Inference Functions for Margins (IFM)
16//! Two-stage estimation:
17//! 1. Estimate marginal parameters
18//! 2. Estimate copula parameters given marginals
19//!
20//! ### Canonical Maximum Likelihood (CML)
21//! Uses empirical CDFs for margins, estimates only copula parameters.
22//!
23//! ## Bibliography
24//! - Joe, H. (2005). Asymptotic efficiency of the two-stage estimation method for copula-based models.
25//! - Genest, C., et al. (1995). A semiparametric estimation procedure of dependence parameters in multivariate families of distributions.
26
27use crate::{Copula, CopulaError, Result};
28use nalgebra::DMatrix;
29
30/// Empirical CDF estimator.
31///
32/// Computes the empirical CDF for univariate data: F̂(x) = (1/n) Σ I(Xᵢ ≤ x)
33pub struct EmpiricalCdf {
34    /// Sorted data points
35    data: Vec<f64>,
36    n: usize,
37}
38
39impl EmpiricalCdf {
40    /// Create a new empirical CDF from data.
41    ///
42    /// # Arguments
43    /// * `data` - The observed data points
44    ///
45    /// # Returns
46    /// An empirical CDF estimator
47    pub fn new(mut data: Vec<f64>) -> Result<Self> {
48        if data.is_empty() {
49            return Err(CopulaError::data_error(
50                "EmpiricalCdf requires non-empty data",
51            ));
52        }
53        if data.iter().any(|x| !x.is_finite()) {
54            return Err(CopulaError::data_error(
55                "EmpiricalCdf data contains non-finite values (NaN or infinite)",
56            ));
57        }
58        data.sort_by(|a, b| a.total_cmp(b));
59        let n = data.len();
60        Ok(Self { data, n })
61    }
62
63    /// Evaluate the empirical CDF at a point.
64    ///
65    /// # Arguments
66    /// * `x` - Point at which to evaluate
67    ///
68    /// # Returns
69    /// F̂(x) = proportion of data points ≤ x
70    pub fn eval(&self, x: f64) -> f64 {
71        if self.n == 0 {
72            return 0.0;
73        }
74
75        // Count how many points are <= x
76        let count = self.data.iter().filter(|&&xi| xi <= x).count();
77        count as f64 / self.n as f64
78    }
79
80    /// Transform data to pseudo-observations using empirical CDF.
81    ///
82    /// Uses the rank-based transformation: û_i = rank(x_i) / (n + 1)
83    pub fn to_pseudo_observations(&self) -> Vec<f64> {
84        let mut pseudo = Vec::with_capacity(self.n);
85
86        for &x in &self.data {
87            // Count values strictly less than x for rank
88            let rank = self.data.iter().filter(|&&xi| xi < x).count() + 1;
89            pseudo.push(rank as f64 / (self.n + 1) as f64);
90        }
91
92        pseudo
93    }
94}
95
96/// Convert multivariate data to pseudo-observations (uniform margins).
97///
98/// # Arguments
99/// * `data` - Matrix of observations (n × d)
100///
101/// # Returns
102/// Matrix of pseudo-observations in [0, 1]^d
103pub fn to_pseudo_observations(data: &DMatrix<f64>) -> Result<DMatrix<f64>> {
104    let n = data.nrows();
105    let d = data.ncols();
106    if n == 0 || d == 0 {
107        return Err(CopulaError::data_error("Data matrix must be non-empty"));
108    }
109    let mut pseudo = DMatrix::<f64>::zeros(n, d);
110
111    // Transform each column independently
112    for j in 0..d {
113        let column: Vec<f64> = (0..n).map(|i| data[(i, j)]).collect();
114        let _ecdf = EmpiricalCdf::new(column.clone())?;
115
116        // Need to map back to original order
117        let mut indexed: Vec<(usize, f64)> =
118            column.iter().enumerate().map(|(i, &x)| (i, x)).collect();
119        indexed.sort_by(|a, b| a.1.total_cmp(&b.1));
120
121        for (new_idx, (orig_idx, _)) in indexed.iter().enumerate() {
122            pseudo[(*orig_idx, j)] = (new_idx + 1) as f64 / (n + 1) as f64;
123        }
124    }
125
126    Ok(pseudo)
127}
128
129/// Estimate Kendall's tau from data.
130///
131/// Kendall's tau is a rank-based measure of dependence:
132/// τ = (# concordant pairs - # discordant pairs) / (n choose 2)
133///
134/// # Arguments
135/// * `x` - First variable
136/// * `y` - Second variable
137///
138/// # Returns
139/// Estimated Kendall's tau ∈ [-1, 1]
140pub fn kendall_tau(x: &[f64], y: &[f64]) -> Result<f64> {
141    if x.len() != y.len() {
142        return Err(CopulaError::dimension_mismatch(x.len(), y.len()));
143    }
144
145    let n = x.len();
146    if n < 2 {
147        return Err(CopulaError::invalid_parameter(
148            "need at least 2 observations",
149        ));
150    }
151
152    let mut concordant = 0;
153    let mut discordant = 0;
154
155    for i in 0..n {
156        for j in (i + 1)..n {
157            let dx = x[j] - x[i];
158            let dy = y[j] - y[i];
159
160            if dx * dy > 0.0 {
161                concordant += 1;
162            } else if dx * dy < 0.0 {
163                discordant += 1;
164            }
165            // If dx*dy == 0, it's a tie, not counted
166        }
167    }
168
169    let total_pairs = (n * (n - 1)) / 2;
170    Ok((concordant - discordant) as f64 / total_pairs as f64)
171}
172
173/// Estimate Spearman's rho from data.
174///
175/// Spearman's rho is the Pearson correlation of ranks:
176/// ρ = cor(rank(X), rank(Y))
177///
178/// # Arguments
179/// * `x` - First variable
180/// * `y` - Second variable
181///
182/// # Returns
183/// Estimated Spearman's rho ∈ [-1, 1]
184pub fn spearman_rho(x: &[f64], y: &[f64]) -> Result<f64> {
185    if x.len() != y.len() {
186        return Err(CopulaError::dimension_mismatch(x.len(), y.len()));
187    }
188
189    let n = x.len();
190    if n < 2 {
191        return Err(CopulaError::invalid_parameter(
192            "need at least 2 observations",
193        ));
194    }
195
196    // Convert to ranks
197    let rank_x = rank(x);
198    let rank_y = rank(y);
199
200    // Compute Pearson correlation of ranks
201    pearson_correlation(&rank_x, &rank_y)
202}
203
204/// Convert data to ranks (average ranks for ties).
205fn rank(data: &[f64]) -> Vec<f64> {
206    let n = data.len();
207    let mut indexed: Vec<(usize, f64)> = data.iter().enumerate().map(|(i, &x)| (i, x)).collect();
208    indexed.sort_by(|a, b| a.1.total_cmp(&b.1));
209
210    let mut ranks = vec![0.0; n];
211    for (rank_pos, (orig_idx, _)) in indexed.iter().enumerate() {
212        ranks[*orig_idx] = (rank_pos + 1) as f64;
213    }
214
215    ranks
216}
217
218/// Compute Pearson correlation coefficient.
219fn pearson_correlation(x: &[f64], y: &[f64]) -> Result<f64> {
220    let n = x.len();
221    if n < 2 {
222        return Err(CopulaError::invalid_parameter(
223            "need at least 2 observations",
224        ));
225    }
226
227    let mean_x: f64 = x.iter().sum::<f64>() / n as f64;
228    let mean_y: f64 = y.iter().sum::<f64>() / n as f64;
229
230    let mut cov = 0.0;
231    let mut var_x = 0.0;
232    let mut var_y = 0.0;
233
234    for i in 0..n {
235        let dx = x[i] - mean_x;
236        let dy = y[i] - mean_y;
237        cov += dx * dy;
238        var_x += dx * dx;
239        var_y += dy * dy;
240    }
241
242    if var_x.abs() < 1e-10 || var_y.abs() < 1e-10 {
243        return Ok(0.0);
244    }
245
246    Ok(cov / (var_x * var_y).sqrt())
247}
248
249/// Canonical Maximum Likelihood (CML) estimator.
250///
251/// Estimates copula parameters using pseudo-observations (empirical marginals).
252pub struct CMLEstimator<'a, C: Copula> {
253    copula: &'a C,
254}
255
256impl<'a, C: Copula> CMLEstimator<'a, C> {
257    /// Create a new CML estimator.
258    pub fn new(copula: &'a C) -> Self {
259        Self { copula }
260    }
261
262    /// Compute the negative log-likelihood for CML estimation.
263    ///
264    /// # Arguments
265    /// * `pseudo_obs` - Pseudo-observations (n × d matrix in [0, 1]^d)
266    ///
267    /// # Returns
268    /// Negative log-likelihood value
269    pub fn neg_log_likelihood(&self, pseudo_obs: &DMatrix<f64>) -> Result<f64> {
270        let n = pseudo_obs.nrows();
271        let d = pseudo_obs.ncols();
272
273        if d != self.copula.dimension() {
274            return Err(CopulaError::dimension_mismatch(self.copula.dimension(), d));
275        }
276
277        let mut log_lik = 0.0;
278
279        for i in 0..n {
280            let u: Vec<f64> = (0..d).map(|j| pseudo_obs[(i, j)]).collect();
281
282            // Evaluate copula density
283            let c = self.copula.pdf(&u)?;
284
285            if c > 0.0 {
286                log_lik += c.ln();
287            } else {
288                // Small value to avoid log(0)
289                log_lik += (-10.0_f64).ln();
290            }
291        }
292
293        Ok(-log_lik)
294    }
295
296    /// Fit copula parameters using CML (placeholder - requires optimization).
297    ///
298    /// In practice, this would use an optimization library to minimize
299    /// the negative log-likelihood over the parameter space.
300    pub fn fit(&self, data: &DMatrix<f64>) -> Result<f64> {
301        // Convert to pseudo-observations
302        let pseudo = to_pseudo_observations(data)?;
303
304        // Compute log-likelihood at current parameters
305        // In practice, would optimize over parameter space here
306        self.neg_log_likelihood(&pseudo)
307    }
308}
309
310/// Method of moments estimator using Kendall's tau.
311///
312/// Many copulas have closed-form relationships between τ and parameters.
313/// For example:
314/// - Clayton: θ = 2τ/(1-τ)
315/// - Gumbel: θ = 1/(1-τ)
316/// - Frank: requires numerical inversion
317pub struct TauEstimator;
318
319impl TauEstimator {
320    /// Estimate Clayton copula parameter from Kendall's tau.
321    ///
322    /// θ = 2τ/(1-τ)
323    pub fn clayton_from_tau(tau: f64) -> Result<f64> {
324        if tau <= -1.0 || tau >= 1.0 {
325            return Err(CopulaError::invalid_parameter("tau must be in (-1, 1)"));
326        }
327        if tau <= 0.0 {
328            return Err(CopulaError::invalid_parameter(
329                "Clayton requires positive tau",
330            ));
331        }
332        Ok(2.0 * tau / (1.0 - tau))
333    }
334
335    /// Estimate Gumbel copula parameter from Kendall's tau.
336    ///
337    /// θ = 1/(1-τ)
338    pub fn gumbel_from_tau(tau: f64) -> Result<f64> {
339        if tau <= 0.0 || tau >= 1.0 {
340            return Err(CopulaError::invalid_parameter(
341                "Gumbel requires tau in (0, 1)",
342            ));
343        }
344        Ok(1.0 / (1.0 - tau))
345    }
346
347    /// Estimate Gaussian copula correlation from Kendall's tau.
348    ///
349    /// ρ ≈ sin(π τ / 2)
350    pub fn gaussian_from_tau(tau: f64) -> Result<f64> {
351        if tau <= -1.0 || tau >= 1.0 {
352            return Err(CopulaError::invalid_parameter("tau must be in (-1, 1)"));
353        }
354        Ok((std::f64::consts::PI * tau / 2.0).sin())
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_empirical_cdf() {
364        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
365        let ecdf = EmpiricalCdf::new(data).unwrap();
366
367        assert_eq!(ecdf.eval(0.0), 0.0);
368        assert_eq!(ecdf.eval(3.0), 0.6); // 3/5
369        assert_eq!(ecdf.eval(6.0), 1.0);
370    }
371
372    #[test]
373    fn test_pseudo_observations() {
374        let data = vec![1.0, 3.0, 2.0, 5.0, 4.0];
375        let ecdf = EmpiricalCdf::new(data).unwrap();
376        let pseudo = ecdf.to_pseudo_observations();
377
378        // All values should be in (0, 1)
379        for &p in &pseudo {
380            assert!(p > 0.0 && p < 1.0);
381        }
382    }
383
384    #[test]
385    fn test_kendall_tau_perfect_concordance() {
386        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
387        let y = vec![2.0, 4.0, 6.0, 8.0, 10.0];
388        let tau = kendall_tau(&x, &y).unwrap();
389        assert!((tau - 1.0).abs() < 1e-10);
390    }
391
392    #[test]
393    fn test_kendall_tau_perfect_discordance() {
394        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
395        let y = vec![10.0, 8.0, 6.0, 4.0, 2.0];
396        let tau = kendall_tau(&x, &y).unwrap();
397        assert!((tau + 1.0).abs() < 1e-10);
398    }
399
400    #[test]
401    fn test_spearman_rho() {
402        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
403        let y = vec![2.0, 4.0, 6.0, 8.0, 10.0];
404        let rho = spearman_rho(&x, &y).unwrap();
405        assert!((rho - 1.0).abs() < 1e-10);
406    }
407
408    #[test]
409    fn test_clayton_from_tau() {
410        let tau = 0.5;
411        let theta = TauEstimator::clayton_from_tau(tau).unwrap();
412        // θ = 2*0.5/(1-0.5) = 1.0/0.5 = 2.0
413        assert!((theta - 2.0).abs() < 1e-10);
414    }
415
416    #[test]
417    fn test_gumbel_from_tau() {
418        let tau = 0.5;
419        let theta = TauEstimator::gumbel_from_tau(tau).unwrap();
420        // θ = 1/(1-0.5) = 2.0
421        assert!((theta - 2.0).abs() < 1e-10);
422    }
423
424    #[test]
425    fn test_to_pseudo_observations() {
426        let data = DMatrix::from_row_slice(3, 2, &[1.0, 5.0, 2.0, 3.0, 3.0, 1.0]);
427
428        let pseudo = to_pseudo_observations(&data).unwrap();
429
430        // Check dimensions
431        assert_eq!(pseudo.nrows(), 3);
432        assert_eq!(pseudo.ncols(), 2);
433
434        // Check all values in (0, 1)
435        for i in 0..3 {
436            for j in 0..2 {
437                assert!(pseudo[(i, j)] > 0.0 && pseudo[(i, j)] < 1.0);
438            }
439        }
440    }
441
442    #[test]
443    fn empirical_cdf_rejects_nan() {
444        let data = vec![1.0, f64::NAN, 3.0];
445        assert!(EmpiricalCdf::new(data).is_err());
446    }
447
448    #[test]
449    fn empirical_cdf_rejects_infinity() {
450        let data = vec![1.0, f64::INFINITY, 3.0];
451        assert!(EmpiricalCdf::new(data).is_err());
452    }
453
454    #[test]
455    fn to_pseudo_observations_rejects_nan() {
456        let data = DMatrix::from_row_slice(2, 2, &[1.0, 2.0, f64::NAN, 4.0]);
457        assert!(to_pseudo_observations(&data).is_err());
458    }
459}