Skip to main content

copula_core/
utils.rs

1//! Utility functions for copula modeling and data preprocessing.
2//!
3//! This module provides essential utility functions for working with copulas,
4//! including data transformation, rank computation, and dependence measures.
5
6use crate::error::{validate_finite_data, CopulaError, Result};
7use nalgebra::DMatrix;
8
9/// Convert raw data to pseudo-observations (empirical copula).
10///
11/// Pseudo-observations are the key input for copula modeling. This function
12/// transforms each marginal distribution to uniform [0, 1] using empirical
13/// ranks, which removes the marginal effects and isolates the dependence structure.
14///
15/// # Mathematical Background
16///
17/// For data X₁, ..., Xₙ, the pseudo-observation for Xᵢ is:
18/// Û_i = R_i / (n + 1)
19/// where R_i is the rank of X_i among X₁, ..., Xₙ.
20///
21/// # Arguments
22///
23/// * `data` - Matrix where each row is an observation and each column is a variable
24///
25/// # Returns
26///
27/// Matrix of pseudo-observations with the same dimensions as input,
28/// where all values are in (0,1).
29///
30/// # Examples
31///
32/// ```rust
33/// use copula_core::to_pseudo_observations;
34/// use nalgebra::DMatrix;
35///
36/// let data = DMatrix::from_row_slice(3, 2, &[
37///     1.0, 4.0,
38///     2.0, 5.0,
39///     3.0, 6.0,
40/// ]);
41///
42/// let pseudo_obs = to_pseudo_observations(&data);
43/// // Result: each column has ranks [0.25, 0.5, 0.75]
44/// ```
45///
46/// # Errors
47///
48/// Returns [`CopulaError::DataError`] if the data contains non-finite values.
49pub fn to_pseudo_observations(data: &DMatrix<f64>) -> Result<DMatrix<f64>> {
50    let (n_rows, n_cols) = data.shape();
51
52    if n_rows == 0 || n_cols == 0 {
53        return Err(CopulaError::data_error("Data matrix is empty"));
54    }
55
56    let mut pseudo_obs = DMatrix::<f64>::zeros(n_rows, n_cols);
57
58    for j in 0..n_cols {
59        let column: Vec<f64> = data.column(j).iter().cloned().collect();
60        validate_finite_data(&column, &format!("column {}", j))?;
61
62        let ranks = empirical_ranks(&column)?;
63
64        for i in 0..n_rows {
65            pseudo_obs[(i, j)] = ranks[i] / (n_rows as f64 + 1.0);
66        }
67    }
68
69    Ok(pseudo_obs)
70}
71
72/// Compute empirical ranks of data points.
73///
74/// Ranks are computed using the standard competition ranking ("1224" ranking):
75/// equal values receive the same rank, and the next value gets the rank it
76/// would have received if all values were distinct.
77///
78/// # Arguments
79///
80/// * `data` - Vector of data points
81///
82/// # Returns
83///
84/// Vector of ranks (1-indexed) with the same length as input.
85///
86/// # Examples
87///
88/// ```rust
89/// use copula_core::empirical_ranks;
90///
91/// let data = vec![3.0, 1.0, 4.0, 1.0, 5.0];
92/// let ranks = empirical_ranks(&data).unwrap();
93/// // ranks = [3.0, 1.5, 4.0, 1.5, 5.0] (average rank for ties)
94/// ```
95///
96/// # Errors
97///
98/// Returns [`CopulaError::DataError`] if the data contains non-finite values.
99pub fn empirical_ranks(data: &[f64]) -> Result<Vec<f64>> {
100    let n = data.len();
101    if n == 0 {
102        return Ok(vec![]);
103    }
104
105    validate_finite_data(data, "input data")?;
106
107    // Create indexed data for sorting while preserving original positions
108    let mut indexed_data: Vec<(f64, usize)> =
109        data.iter().enumerate().map(|(i, &x)| (x, i)).collect();
110
111    // Sort by value
112    indexed_data.sort_by(|a, b| a.0.total_cmp(&b.0));
113
114    let mut ranks = vec![0.0; n];
115    let mut i = 0;
116
117    while i < n {
118        let current_value = indexed_data[i].0;
119        let start_rank = i + 1; // 1-indexed
120
121        // Find all equal values
122        let mut j = i;
123        while j < n && (indexed_data[j].0 - current_value).abs() < f64::EPSILON {
124            j += 1;
125        }
126
127        // Assign average rank to all tied values
128        let avg_rank = (start_rank + (i + (j - i))) as f64 / 2.0;
129        for &(_, idx) in &indexed_data[i..j] {
130            ranks[idx] = avg_rank;
131        }
132
133        i = j;
134    }
135
136    Ok(ranks)
137}
138
139/// Compute Kendall's tau correlation coefficient.
140///
141/// Kendall's tau measures the ordinal association between two variables
142/// and is particularly suitable for copula modeling as it depends only
143/// on ranks, not on marginal distributions.
144///
145/// # Mathematical Background
146///
147/// For paired observations (x₁, y₁), ..., (xₙ, yₙ), Kendall's tau is:
148/// τ = (C - D) / (C + D)
149/// where C is the number of concordant pairs and D is the number of discordant pairs.
150///
151/// # Arguments
152///
153/// * `x` - First variable
154/// * `y` - Second variable (must have same length as x)
155///
156/// # Returns
157///
158/// Kendall's tau in [-1, 1], where:
159/// - 1 indicates perfect positive dependence
160/// - 0 indicates independence  
161/// - -1 indicates perfect negative dependence
162///
163/// # Examples
164///
165/// ```rust
166/// use copula_core::kendall_tau;
167///
168/// let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
169/// let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
170/// let tau = kendall_tau(&x, &y).unwrap();
171/// // tau ≈ 1.0 (perfect positive correlation)
172/// ```
173///
174/// # Errors
175///
176/// Returns [`CopulaError::DimensionMismatch`] if x and y have different lengths.
177/// Returns [`CopulaError::DataError`] if data contains non-finite values.
178pub fn kendall_tau(x: &[f64], y: &[f64]) -> Result<f64> {
179    if x.len() != y.len() {
180        return Err(CopulaError::dimension_mismatch(x.len(), y.len()));
181    }
182
183    let n = x.len();
184    if n < 2 {
185        return Err(CopulaError::data_error("Need at least 2 observations"));
186    }
187
188    validate_finite_data(x, "x variable")?;
189    validate_finite_data(y, "y variable")?;
190
191    let mut concordant = 0;
192    let mut discordant = 0;
193
194    for i in 0..n {
195        for j in (i + 1)..n {
196            let x_diff = x[i] - x[j];
197            let y_diff = y[i] - y[j];
198            let product = x_diff * y_diff;
199
200            if product > 0.0 {
201                concordant += 1;
202            } else if product < 0.0 {
203                discordant += 1;
204            }
205            // Equal values contribute neither to concordant nor discordant
206        }
207    }
208
209    let total_pairs = concordant + discordant;
210    if total_pairs == 0 {
211        Ok(0.0) // All pairs are tied
212    } else {
213        Ok((concordant as f64 - discordant as f64) / total_pairs as f64)
214    }
215}
216
217/// Compute Spearman's rho correlation coefficient.
218///
219/// Spearman's rho is the Pearson correlation of the ranks, providing another
220/// measure of monotonic association that's robust to outliers.
221///
222/// # Mathematical Background
223///
224/// Spearman's rho is computed as the Pearson correlation between the ranks:
225/// ρ = cor(rank(X), rank(Y))
226///
227/// # Arguments
228///
229/// * `x` - First variable
230/// * `y` - Second variable (must have same length as x)
231///
232/// # Returns
233///
234/// Spearman's rho in [-1, 1].
235///
236/// # Examples
237///
238/// ```rust
239/// use copula_core::spearman_rho;
240///
241/// let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
242/// let y = vec![5.0, 4.0, 3.0, 2.0, 1.0];
243/// let rho = spearman_rho(&x, &y).unwrap();
244/// // rho ≈ -1.0 (perfect negative correlation)
245/// ```
246///
247/// # Errors
248///
249/// Returns [`CopulaError::DimensionMismatch`] if x and y have different lengths.
250/// Returns [`CopulaError::DataError`] if data contains non-finite values.
251pub fn spearman_rho(x: &[f64], y: &[f64]) -> Result<f64> {
252    if x.len() != y.len() {
253        return Err(CopulaError::dimension_mismatch(x.len(), y.len()));
254    }
255
256    let n = x.len();
257    if n < 2 {
258        return Err(CopulaError::data_error("Need at least 2 observations"));
259    }
260
261    let ranks_x = empirical_ranks(x)?;
262    let ranks_y = empirical_ranks(y)?;
263
264    pearson_correlation(&ranks_x, &ranks_y)
265}
266
267/// Compute Pearson correlation coefficient.
268///
269/// # Arguments
270///
271/// * `x` - First variable
272/// * `y` - Second variable
273///
274/// # Returns
275///
276/// Pearson correlation in [-1, 1].
277fn pearson_correlation(x: &[f64], y: &[f64]) -> Result<f64> {
278    let n = x.len() as f64;
279
280    let mean_x = x.iter().sum::<f64>() / n;
281    let mean_y = y.iter().sum::<f64>() / n;
282
283    let mut numerator = 0.0;
284    let mut sum_sq_x = 0.0;
285    let mut sum_sq_y = 0.0;
286
287    for i in 0..x.len() {
288        let dx = x[i] - mean_x;
289        let dy = y[i] - mean_y;
290
291        numerator += dx * dy;
292        sum_sq_x += dx * dx;
293        sum_sq_y += dy * dy;
294    }
295
296    let denominator = (sum_sq_x * sum_sq_y).sqrt();
297
298    if denominator < f64::EPSILON {
299        Ok(0.0) // No variance in one or both variables
300    } else {
301        Ok(numerator / denominator)
302    }
303}
304
305/// Transform data using the empirical CDF.
306///
307/// This is an alternative to `to_pseudo_observations` that uses the empirical
308/// CDF directly rather than ranks.
309///
310/// # Arguments
311///
312/// * `data` - Matrix of data
313///
314/// # Returns
315///
316/// Matrix of transformed data where each column has been transformed
317/// to approximately uniform using its empirical CDF.
318pub fn empirical_cdf_transform(data: &DMatrix<f64>) -> Result<DMatrix<f64>> {
319    let (n_rows, n_cols) = data.shape();
320    let mut transformed = DMatrix::<f64>::zeros(n_rows, n_cols);
321
322    for j in 0..n_cols {
323        let column: Vec<f64> = data.column(j).iter().cloned().collect();
324        validate_finite_data(&column, &format!("column {}", j))?;
325
326        let mut sorted_column = column.clone();
327        sorted_column.sort_by(|a, b| a.total_cmp(b));
328
329        for i in 0..n_rows {
330            let value = column[i];
331            let rank = sorted_column
332                .iter()
333                .position(|&x| x >= value)
334                .unwrap_or(n_rows - 1);
335
336            transformed[(i, j)] = (rank + 1) as f64 / (n_rows + 1) as f64;
337        }
338    }
339
340    Ok(transformed)
341}
342
343/// The value of any copula at a point with a coordinate equal to 0 or 1, when
344/// it follows from the copula axioms alone.
345///
346/// C(u) = 0 if some u_i = 0. Coordinates equal to 1 drop out, so C(u) = 1 when
347/// every u_i = 1 and C(u) = u_j when u_j is the only coordinate below 1.
348/// Returns `None` when at least two coordinates lie strictly inside (0, 1).
349/// `u` must already be validated to lie in [0, 1].
350pub(crate) fn copula_boundary_value(u: &[f64]) -> Option<f64> {
351    if u.contains(&0.0) {
352        return Some(0.0);
353    }
354    let mut interior = u.iter().copied().filter(|&x| x < 1.0);
355    match (interior.next(), interior.next()) {
356        (None, _) => Some(1.0),
357        (Some(only), None) => Some(only),
358        _ => None,
359    }
360}
361
362/// Clamp a computed copula CDF value to the Fréchet–Hoeffding bounds
363/// max(u_1 + ... + u_d - d + 1, 0) <= C(u) <= min(u_1, ..., u_d).
364///
365/// Every copula satisfies these bounds. Finite-precision evaluation can land
366/// slightly outside them, for example a value of -3e-19 where the true CDF is
367/// essentially 0; clamping keeps results valid, including within [0, 1].
368/// `u` must already be validated to lie in [0, 1].
369pub(crate) fn clamp_to_frechet_bounds(u: &[f64], value: f64) -> f64 {
370    let d = u.len() as f64;
371    let upper = u.iter().copied().fold(1.0, f64::min);
372    // Rounding in the sum can put the lower bound above the upper bound, for
373    // example at u = (0.01, 1.0); `f64::clamp` panics if min > max.
374    let lower = (u.iter().sum::<f64>() - d + 1.0).max(0.0).min(upper);
375    value.clamp(lower, upper)
376}
377
378/// Check if a matrix is a valid correlation matrix.
379///
380/// A valid correlation matrix must be:
381/// 1. Square
382/// 2. Symmetric
383/// 3. Have unit diagonal
384/// 4. Be positive semi-definite
385/// 5. Have only finite entries
386///
387/// # Arguments
388///
389/// * `matrix` - Matrix to validate
390///
391/// # Returns
392///
393/// `Ok(())` if valid, error otherwise.
394pub fn validate_correlation_matrix(matrix: &DMatrix<f64>) -> Result<()> {
395    let (n_rows, n_cols) = matrix.shape();
396
397    // Check if square
398    if n_rows != n_cols {
399        return Err(CopulaError::invalid_parameter(
400            "Correlation matrix must be square",
401        ));
402    }
403
404    let n = n_rows;
405
406    // NaN fails every comparison below, so reject non-finite entries first.
407    if matrix.iter().any(|x| !x.is_finite()) {
408        return Err(CopulaError::invalid_parameter(
409            "Correlation matrix entries must be finite",
410        ));
411    }
412
413    // Check symmetry and unit diagonal
414    for i in 0..n {
415        // Check diagonal
416        if (matrix[(i, i)] - 1.0).abs() > 1e-10 {
417            return Err(CopulaError::invalid_parameter(
418                "Correlation matrix must have unit diagonal",
419            ));
420        }
421
422        // Check symmetry
423        for j in 0..n {
424            if (matrix[(i, j)] - matrix[(j, i)]).abs() > 1e-10 {
425                return Err(CopulaError::invalid_parameter(
426                    "Correlation matrix must be symmetric",
427                ));
428            }
429        }
430
431        // Check off-diagonal bounds
432        for j in 0..n {
433            if i != j && (matrix[(i, j)].abs() > 1.0) {
434                return Err(CopulaError::invalid_parameter(
435                    "Correlation coefficients must be in [-1, 1]",
436                ));
437            }
438        }
439    }
440
441    // Check positive semi-definiteness using eigenvalues
442    let eigenvalues = matrix.symmetric_eigenvalues();
443    let min_eigenvalue = eigenvalues.iter().fold(f64::INFINITY, |a, &b| a.min(b));
444
445    if min_eigenvalue < -1e-10 {
446        return Err(CopulaError::invalid_parameter(
447            "Correlation matrix must be positive semi-definite",
448        ));
449    }
450
451    Ok(())
452}
453
454/// Generate a random correlation matrix.
455///
456/// Uses the method of Joe (2006) to generate a random correlation matrix
457/// that is guaranteed to be positive definite.
458///
459/// # Arguments
460///
461/// * `dimension` - Size of the correlation matrix
462/// * `rng` - Random number generator
463///
464/// # Returns
465///
466/// A random positive definite correlation matrix.
467///
468/// # Examples
469///
470/// ```rust
471/// use copula_core::utils::random_correlation_matrix;
472///
473/// let mut rng = rand::rng();
474/// let corr = random_correlation_matrix(3, &mut rng)?;
475/// assert_eq!(corr.shape(), (3, 3));
476/// # Ok::<(), copula_core::CopulaError>(())
477/// ```
478pub fn random_correlation_matrix<R: rand::Rng + ?Sized>(
479    dimension: usize,
480    rng: &mut R,
481) -> Result<DMatrix<f64>> {
482    use rand_distr::{Distribution, StandardNormal};
483
484    if dimension == 0 {
485        return Err(CopulaError::invalid_parameter("Dimension must be positive"));
486    }
487
488    if dimension == 1 {
489        return Ok(DMatrix::from_element(1, 1, 1.0));
490    }
491
492    // Generate random matrix
493    let mut a = DMatrix::<f64>::zeros(dimension, dimension);
494    let normal = StandardNormal;
495
496    for i in 0..dimension {
497        for j in 0..dimension {
498            a[(i, j)] = normal.sample(rng);
499        }
500    }
501
502    // Compute A'A to get positive semi-definite matrix
503    let ata = a.transpose() * &a;
504
505    // Extract diagonal for normalization
506    let mut corr = DMatrix::<f64>::zeros(dimension, dimension);
507    for i in 0..dimension {
508        for j in 0..dimension {
509            corr[(i, j)] = ata[(i, j)] / (ata[(i, i)] * ata[(j, j)]).sqrt();
510        }
511    }
512
513    Ok(corr)
514}
515
516/// Compute the empirical copula CDF at a given point.
517///
518/// The empirical copula is the non-parametric maximum likelihood estimator
519/// of the copula function.
520///
521/// # Arguments
522///
523/// * `pseudo_obs` - Matrix of pseudo-observations
524/// * `u` - Point at which to evaluate the empirical copula
525///
526/// # Returns
527///
528/// Empirical copula value at u.
529///
530/// # Examples
531///
532/// ```rust
533/// use copula_core::utils::{empirical_copula_cdf, to_pseudo_observations};
534/// use nalgebra::DMatrix;
535///
536/// let data = DMatrix::from_row_slice(4, 2, &[1.2, 0.3, 0.7, 0.9, 2.5, 1.1, 1.9, 2.0]);
537/// let pseudo_obs = to_pseudo_observations(&data)?;
538/// let cdf_val = empirical_copula_cdf(&pseudo_obs, &[0.5, 0.5])?;
539/// assert!((0.0..=1.0).contains(&cdf_val));
540/// # Ok::<(), copula_core::CopulaError>(())
541/// ```
542pub fn empirical_copula_cdf(pseudo_obs: &DMatrix<f64>, u: &[f64]) -> Result<f64> {
543    let (n_rows, n_cols) = pseudo_obs.shape();
544
545    if u.len() != n_cols {
546        return Err(CopulaError::dimension_mismatch(n_cols, u.len()));
547    }
548
549    crate::error::validate_unit_range(u)?;
550
551    let count = (0..n_rows)
552        .filter(|&i| (0..n_cols).all(|j| pseudo_obs[(i, j)] <= u[j]))
553        .count();
554
555    Ok(count as f64 / n_rows as f64)
556}
557
558/// Compute the sample version of Kendall's tau for multivariate data.
559///
560/// This computes the pairwise Kendall's tau for all variable pairs.
561///
562/// # Arguments
563///
564/// * `data` - Data matrix where each column is a variable
565///
566/// # Returns
567///
568/// Symmetric matrix of pairwise Kendall's tau values.
569pub fn multivariate_kendall_tau(data: &DMatrix<f64>) -> Result<DMatrix<f64>> {
570    let (n_rows, n_cols) = data.shape();
571
572    if n_rows < 2 {
573        return Err(CopulaError::data_error("Need at least 2 observations"));
574    }
575
576    let mut tau_matrix = DMatrix::<f64>::zeros(n_cols, n_cols);
577
578    for i in 0..n_cols {
579        tau_matrix[(i, i)] = 1.0; // Diagonal is 1
580
581        for j in (i + 1)..n_cols {
582            let col_i: Vec<f64> = data.column(i).iter().cloned().collect();
583            let col_j: Vec<f64> = data.column(j).iter().cloned().collect();
584
585            let tau = kendall_tau(&col_i, &col_j)?;
586            tau_matrix[(i, j)] = tau;
587            tau_matrix[(j, i)] = tau; // Symmetric
588        }
589    }
590
591    Ok(tau_matrix)
592}
593
594/// Compute the sample version of Spearman's rho for multivariate data.
595///
596/// This computes the pairwise Spearman's rho for all variable pairs.
597///
598/// # Arguments
599///
600/// * `data` - Data matrix where each column is a variable
601///
602/// # Returns
603///
604/// Symmetric matrix of pairwise Spearman's rho values.
605pub fn multivariate_spearman_rho(data: &DMatrix<f64>) -> Result<DMatrix<f64>> {
606    let (n_rows, n_cols) = data.shape();
607
608    if n_rows < 2 {
609        return Err(CopulaError::data_error("Need at least 2 observations"));
610    }
611
612    let mut rho_matrix = DMatrix::<f64>::zeros(n_cols, n_cols);
613
614    for i in 0..n_cols {
615        rho_matrix[(i, i)] = 1.0; // Diagonal is 1
616
617        for j in (i + 1)..n_cols {
618            let col_i: Vec<f64> = data.column(i).iter().cloned().collect();
619            let col_j: Vec<f64> = data.column(j).iter().cloned().collect();
620
621            let rho = spearman_rho(&col_i, &col_j)?;
622            rho_matrix[(i, j)] = rho;
623            rho_matrix[(j, i)] = rho; // Symmetric
624        }
625    }
626
627    Ok(rho_matrix)
628}
629
630/// Remove observations with missing values (NaN).
631///
632/// This function removes entire rows that contain any NaN values.
633///
634/// # Arguments
635///
636/// * `data` - Data matrix that may contain NaN values
637///
638/// # Returns
639///
640/// Data matrix with rows containing NaN removed.
641pub fn remove_missing_values(data: &DMatrix<f64>) -> DMatrix<f64> {
642    let (n_rows, n_cols) = data.shape();
643
644    let valid_rows: Vec<usize> = (0..n_rows)
645        .filter(|&i| (0..n_cols).all(|j| data[(i, j)].is_finite()))
646        .collect();
647
648    if valid_rows.is_empty() {
649        return DMatrix::<f64>::zeros(0, n_cols);
650    }
651
652    let mut clean_data = DMatrix::<f64>::zeros(valid_rows.len(), n_cols);
653
654    for (new_i, &old_i) in valid_rows.iter().enumerate() {
655        for j in 0..n_cols {
656            clean_data[(new_i, j)] = data[(old_i, j)];
657        }
658    }
659
660    clean_data
661}
662
663/// Bootstrap resample from a dataset.
664///
665/// Generate a bootstrap sample by sampling with replacement.
666///
667/// # Arguments
668///
669/// * `data` - Original data matrix
670/// * `rng` - Random number generator
671///
672/// # Returns
673///
674/// Bootstrap sample with the same dimensions as the original data.
675pub fn bootstrap_sample<R: rand::Rng + ?Sized>(data: &DMatrix<f64>, rng: &mut R) -> DMatrix<f64> {
676    let (n_rows, n_cols) = data.shape();
677    let mut bootstrap_data = DMatrix::<f64>::zeros(n_rows, n_cols);
678
679    use rand::seq::IndexedRandom;
680    let indices: Vec<usize> = (0..n_rows).collect();
681
682    for i in 0..n_rows {
683        let &sampled_idx = indices.choose(rng).unwrap();
684        for j in 0..n_cols {
685            bootstrap_data[(i, j)] = data[(sampled_idx, j)];
686        }
687    }
688
689    bootstrap_data
690}
691
692/// Compute information criteria for model selection.
693///
694/// # Arguments
695///
696/// * `log_likelihood` - Log-likelihood of the model
697/// * `n_params` - Number of parameters in the model
698/// * `n_obs` - Number of observations
699///
700/// # Returns
701///
702/// Tuple of (AIC, BIC) values.
703pub fn information_criteria(log_likelihood: f64, n_params: usize, n_obs: usize) -> (f64, f64) {
704    let aic = -2.0 * log_likelihood + 2.0 * n_params as f64;
705    let bic = -2.0 * log_likelihood + (n_params as f64) * (n_obs as f64).ln();
706    (aic, bic)
707}
708
709/// Validate that pseudo-observations are in the correct range.
710///
711/// Pseudo-observations should be in (0, 1), not exactly 0 or 1.
712///
713/// # Arguments
714///
715/// * `pseudo_obs` - Matrix of pseudo-observations to validate
716///
717/// # Returns
718///
719/// `Ok(())` if valid, error otherwise.
720pub fn validate_pseudo_observations(pseudo_obs: &DMatrix<f64>) -> Result<()> {
721    let (n_rows, n_cols) = pseudo_obs.shape();
722
723    for i in 0..n_rows {
724        for j in 0..n_cols {
725            let val = pseudo_obs[(i, j)];
726            if !val.is_finite() || val <= 0.0 || val >= 1.0 {
727                return Err(CopulaError::data_error(format!(
728                    "Pseudo-observation at ({}, {}) = {} is not in (0, 1)",
729                    i, j, val
730                )));
731            }
732        }
733    }
734
735    Ok(())
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use approx::assert_relative_eq;
742    use nalgebra::DMatrix;
743
744    #[test]
745    fn test_empirical_ranks() {
746        let data = vec![3.0, 1.0, 4.0, 1.0, 5.0];
747        let ranks = empirical_ranks(&data).unwrap();
748
749        // Expected: [3, 1.5, 4, 1.5, 5] (average rank for ties)
750        assert_relative_eq!(ranks[0], 3.0);
751        assert_relative_eq!(ranks[1], 1.5);
752        assert_relative_eq!(ranks[2], 4.0);
753        assert_relative_eq!(ranks[3], 1.5);
754        assert_relative_eq!(ranks[4], 5.0);
755    }
756
757    #[test]
758    fn test_to_pseudo_observations() {
759        let data = DMatrix::from_row_slice(3, 2, &[1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
760
761        let pseudo_obs = to_pseudo_observations(&data).unwrap();
762
763        // Each column should have values [0.25, 0.5, 0.75]
764        for j in 0..2 {
765            assert_relative_eq!(pseudo_obs[(0, j)], 0.25, epsilon = 1e-10);
766            assert_relative_eq!(pseudo_obs[(1, j)], 0.5, epsilon = 1e-10);
767            assert_relative_eq!(pseudo_obs[(2, j)], 0.75, epsilon = 1e-10);
768        }
769    }
770
771    #[test]
772    fn test_kendall_tau() {
773        // Perfect positive correlation
774        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
775        let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
776        let tau = kendall_tau(&x, &y).unwrap();
777        assert_relative_eq!(tau, 1.0, epsilon = 1e-10);
778
779        // Perfect negative correlation
780        let y_neg = vec![5.0, 4.0, 3.0, 2.0, 1.0];
781        let tau_neg = kendall_tau(&x, &y_neg).unwrap();
782        assert_relative_eq!(tau_neg, -1.0, epsilon = 1e-10);
783    }
784
785    #[test]
786    fn test_spearman_rho() {
787        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
788        let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
789        let rho = spearman_rho(&x, &y).unwrap();
790        assert_relative_eq!(rho, 1.0, epsilon = 1e-10);
791    }
792
793    #[test]
794    fn test_validate_correlation_matrix() {
795        // Valid correlation matrix
796        let valid = DMatrix::from_row_slice(2, 2, &[1.0, 0.5, 0.5, 1.0]);
797        assert!(validate_correlation_matrix(&valid).is_ok());
798
799        // Invalid: not symmetric
800        let invalid = DMatrix::from_row_slice(2, 2, &[1.0, 0.5, 0.3, 1.0]);
801        assert!(validate_correlation_matrix(&invalid).is_err());
802
803        // Invalid: diagonal not 1
804        let invalid2 = DMatrix::from_row_slice(2, 2, &[0.9, 0.5, 0.5, 1.0]);
805        assert!(validate_correlation_matrix(&invalid2).is_err());
806    }
807
808    #[test]
809    fn test_empirical_copula_cdf() {
810        let pseudo_obs = DMatrix::from_row_slice(4, 2, &[0.1, 0.1, 0.3, 0.4, 0.6, 0.4, 0.8, 0.9]);
811
812        // Point (0.5, 0.5) should have 2 observations ≤ it
813        let cdf = empirical_copula_cdf(&pseudo_obs, &[0.5, 0.5]).unwrap();
814        assert_relative_eq!(cdf, 0.5, epsilon = 1e-10);
815    }
816
817    #[test]
818    fn test_remove_missing_values() {
819        let data = DMatrix::from_row_slice(3, 2, &[1.0, 2.0, f64::NAN, 4.0, 5.0, 6.0]);
820
821        let clean = remove_missing_values(&data);
822        assert_eq!(clean.nrows(), 2);
823        assert_eq!(clean[(0, 0)], 1.0);
824        assert_eq!(clean[(1, 0)], 5.0);
825    }
826
827    #[test]
828    fn test_information_criteria() {
829        let (aic, bic) = information_criteria(-100.0, 3, 100);
830        assert_eq!(aic, 206.0); // -2*(-100) + 2*3
831        assert_relative_eq!(bic, 200.0 + 3.0 * 100.0_f64.ln(), epsilon = 1e-10);
832    }
833
834    #[test]
835    fn test_validate_pseudo_observations() {
836        let valid = DMatrix::from_row_slice(2, 2, &[0.5, 0.6, 0.7, 0.8]);
837        assert!(validate_pseudo_observations(&valid).is_ok());
838
839        let invalid = DMatrix::from_row_slice(1, 2, &[1.0, 0.5]);
840        assert!(validate_pseudo_observations(&invalid).is_err());
841
842        let invalid_nan = DMatrix::from_row_slice(1, 1, &[f64::NAN]);
843        assert!(validate_pseudo_observations(&invalid_nan).is_err());
844    }
845
846    #[test]
847    fn test_random_correlation_matrix() {
848        let mut rng = rand::rng();
849        let corr = random_correlation_matrix(3, &mut rng).unwrap();
850        assert_eq!(corr.nrows(), 3);
851        assert!(validate_correlation_matrix(&corr).is_ok());
852
853        assert!(random_correlation_matrix(0, &mut rng).is_err());
854    }
855
856    #[test]
857    fn test_empirical_cdf_transform() {
858        let data = DMatrix::from_row_slice(
859            5,
860            2,
861            &[1.0, 10.0, 2.0, 20.0, 3.0, 30.0, 4.0, 40.0, 5.0, 50.0],
862        );
863        let transformed = empirical_cdf_transform(&data).unwrap();
864        assert_eq!(transformed.nrows(), 5);
865        assert_eq!(transformed.ncols(), 2);
866        for i in 0..5 {
867            for j in 0..2 {
868                let v = transformed[(i, j)];
869                assert!(v > 0.0 && v < 1.0, "value {} not in (0,1)", v);
870            }
871        }
872    }
873
874    #[test]
875    fn test_empirical_cdf_transform_rejects_nan() {
876        let data = DMatrix::from_row_slice(2, 1, &[1.0, f64::NAN]);
877        assert!(empirical_cdf_transform(&data).is_err());
878    }
879
880    #[test]
881    fn test_multivariate_kendall_tau() {
882        #[rustfmt::skip]
883        let data = DMatrix::from_row_slice(5, 3, &[
884            1.0, 1.0, 1.0,
885            2.0, 2.0, 2.0,
886            3.0, 3.0, 3.0,
887            4.0, 4.0, 4.0,
888            5.0, 5.0, 5.0,
889        ]);
890        let tau = multivariate_kendall_tau(&data).unwrap();
891        assert_eq!(tau.nrows(), 3);
892        assert_eq!(tau.ncols(), 3);
893        for i in 0..3 {
894            assert_relative_eq!(tau[(i, i)], 1.0, epsilon = 1e-10);
895            for j in 0..3 {
896                assert_relative_eq!(tau[(i, j)], 1.0, epsilon = 1e-10);
897            }
898        }
899    }
900
901    #[test]
902    fn test_multivariate_kendall_tau_rejects_insufficient_data() {
903        let data = DMatrix::from_row_slice(1, 2, &[1.0, 2.0]);
904        assert!(multivariate_kendall_tau(&data).is_err());
905    }
906
907    #[test]
908    fn test_multivariate_spearman_rho() {
909        let data =
910            DMatrix::from_row_slice(5, 2, &[1.0, 5.0, 2.0, 4.0, 3.0, 3.0, 4.0, 2.0, 5.0, 1.0]);
911        let rho = multivariate_spearman_rho(&data).unwrap();
912        assert_eq!(rho.nrows(), 2);
913        assert_relative_eq!(rho[(0, 0)], 1.0, epsilon = 1e-10);
914        assert_relative_eq!(rho[(1, 1)], 1.0, epsilon = 1e-10);
915        assert_relative_eq!(rho[(0, 1)], -1.0, epsilon = 1e-10);
916    }
917
918    #[test]
919    fn test_empirical_ranks_empty() {
920        let ranks = empirical_ranks(&[]).unwrap();
921        assert!(ranks.is_empty());
922    }
923
924    #[test]
925    fn test_empirical_ranks_single() {
926        let ranks = empirical_ranks(&[42.0]).unwrap();
927        assert_eq!(ranks.len(), 1);
928        assert_relative_eq!(ranks[0], 1.0);
929    }
930
931    #[test]
932    fn test_empirical_ranks_rejects_nan() {
933        assert!(empirical_ranks(&[1.0, f64::NAN, 3.0]).is_err());
934    }
935
936    #[test]
937    fn test_pseudo_observations_rejects_empty() {
938        let data = DMatrix::<f64>::zeros(0, 2);
939        assert!(to_pseudo_observations(&data).is_err());
940    }
941
942    #[test]
943    fn test_bootstrap_sample_dimensions() {
944        let mut rng = rand::rng();
945        let data =
946            DMatrix::from_row_slice(5, 2, &[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1]);
947        let boot = bootstrap_sample(&data, &mut rng);
948        assert_eq!(boot.nrows(), 5);
949        assert_eq!(boot.ncols(), 2);
950    }
951
952    #[test]
953    fn test_random_correlation_matrix_1d() {
954        let mut rng = rand::rng();
955        let corr = random_correlation_matrix(1, &mut rng).unwrap();
956        assert_eq!(corr.nrows(), 1);
957        assert_relative_eq!(corr[(0, 0)], 1.0);
958    }
959}