use crate::error::GeomError;
use crate::linalg::cholesky::cholesky;
use crate::linalg::matrix::Matrix;
use crate::monte_carlo::Rng;
use crate::statistics::distributions::{Distribution, Normal, StudentT};
const SHAPE_TOL: f64 = 1e-10;
fn gev_reduced(x: f64, mu: f64, sigma: f64, xi: f64) -> f64 {
1.0 + xi * (x - mu) / sigma
}
#[must_use]
pub fn gev_pdf(x: f64, mu: f64, sigma: f64, xi: f64) -> f64 {
assert!(sigma > 0.0, "gev_pdf requires a positive scale");
let z = (x - mu) / sigma;
if xi.abs() < SHAPE_TOL {
return (-z - (-z).exp()).exp() / sigma;
}
let s = gev_reduced(x, mu, sigma, xi);
if s <= 0.0 {
return 0.0;
}
let t = s.powf(-1.0 / xi);
t.powf(xi + 1.0) * (-t).exp() / sigma
}
#[must_use]
pub fn gev_cdf(x: f64, mu: f64, sigma: f64, xi: f64) -> f64 {
assert!(sigma > 0.0, "gev_cdf requires a positive scale");
let z = (x - mu) / sigma;
if xi.abs() < SHAPE_TOL {
return (-(-z).exp()).exp();
}
let s = gev_reduced(x, mu, sigma, xi);
if s <= 0.0 {
return if xi > 0.0 { 0.0 } else { 1.0 };
}
(-s.powf(-1.0 / xi)).exp()
}
#[must_use]
pub fn gev_quantile(p: f64, mu: f64, sigma: f64, xi: f64) -> f64 {
assert!(sigma > 0.0, "gev_quantile requires a positive scale");
assert!(p > 0.0 && p < 1.0, "gev_quantile requires p in (0, 1)");
let y = -p.ln();
if xi.abs() < SHAPE_TOL {
mu - sigma * y.ln()
} else {
mu + sigma * (y.powf(-xi) - 1.0) / xi
}
}
fn gev_nll(data: &[f64], mu: f64, sigma: f64, xi: f64) -> f64 {
if !(sigma > 0.0) || !sigma.is_finite() {
return f64::MAX;
}
let n = data.len() as f64;
if xi.abs() < SHAPE_TOL {
let mut acc = n * sigma.ln();
for &x in data {
let z = (x - mu) / sigma;
acc += z + (-z).exp();
}
return if acc.is_finite() { acc } else { f64::MAX };
}
let mut acc = n * sigma.ln();
for &x in data {
let s = gev_reduced(x, mu, sigma, xi);
if s <= 1e-300 {
return f64::MAX;
}
acc += (1.0 + 1.0 / xi) * s.ln() + s.powf(-1.0 / xi);
}
if acc.is_finite() {
acc
} else {
f64::MAX
}
}
pub fn gev_fit(maxima: &[f64]) -> Result<(f64, f64, f64), GeomError> {
if maxima.len() < 10 {
return Err(GeomError::InvalidArgument("gev_fit requires at least ten maxima"));
}
let (g_mu, g_sigma) = gumbel_moment_start(maxima)?;
let objective = |p: &[f64]| -> f64 {
gev_nll(maxima, p[0], p[1].clamp(-40.0, 40.0).exp(), p[2])
};
let start = [g_mu, g_sigma.ln(), 0.05];
let best = crate::optimization::nelder_mead(&objective, &start, 0.2, 1e-12, 4000);
let (mu, sigma, xi) = (best[0], best[1].clamp(-40.0, 40.0).exp(), best[2]);
if !mu.is_finite() || !(sigma > 0.0) || !xi.is_finite() {
return Err(GeomError::Degenerate("gev_fit: the optimiser produced no fit"));
}
if gev_nll(maxima, mu, sigma, xi) >= f64::MAX {
return Err(GeomError::Degenerate("gev_fit: no feasible parameters found"));
}
Ok((mu, sigma, xi))
}
fn gumbel_moment_start(data: &[f64]) -> Result<(f64, f64), GeomError> {
let n = data.len() as f64;
let mean: f64 = data.iter().sum::<f64>() / n;
let var: f64 = data.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n;
if !(var > 0.0) {
return Err(GeomError::Degenerate("the sample has no variation"));
}
let sigma = (6.0 * var).sqrt() / std::f64::consts::PI;
const EULER_MASCHERONI: f64 = 0.577_215_664_901_532_9;
Ok((mean - EULER_MASCHERONI * sigma, sigma))
}
pub fn gumbel_fit(maxima: &[f64]) -> Result<(f64, f64), GeomError> {
if maxima.len() < 5 {
return Err(GeomError::InvalidArgument("gumbel_fit requires at least five maxima"));
}
let (mu0, sigma0) = gumbel_moment_start(maxima)?;
let objective =
|p: &[f64]| -> f64 { gev_nll(maxima, p[0], p[1].clamp(-40.0, 40.0).exp(), 0.0) };
let best = crate::optimization::nelder_mead(&objective, &[mu0, sigma0.ln()], 0.2, 1e-12, 3000);
let sigma = best[1].clamp(-40.0, 40.0).exp();
if !best[0].is_finite() || !(sigma > 0.0) {
return Err(GeomError::Degenerate("gumbel_fit: the optimiser produced no fit"));
}
Ok((best[0], sigma))
}
#[must_use]
pub fn gpd_pdf(y: f64, sigma: f64, xi: f64) -> f64 {
assert!(sigma > 0.0, "gpd_pdf requires a positive scale");
if y < 0.0 {
return 0.0;
}
if xi.abs() < SHAPE_TOL {
return (-y / sigma).exp() / sigma;
}
let s = 1.0 + xi * y / sigma;
if s <= 0.0 {
return 0.0;
}
s.powf(-1.0 / xi - 1.0) / sigma
}
#[must_use]
pub fn gpd_cdf(y: f64, sigma: f64, xi: f64) -> f64 {
assert!(sigma > 0.0, "gpd_cdf requires a positive scale");
if y <= 0.0 {
return 0.0;
}
if xi.abs() < SHAPE_TOL {
return 1.0 - (-y / sigma).exp();
}
let s = 1.0 + xi * y / sigma;
if s <= 0.0 {
return 1.0;
}
1.0 - s.powf(-1.0 / xi)
}
#[must_use]
pub fn gpd_quantile(p: f64, sigma: f64, xi: f64) -> f64 {
assert!(sigma > 0.0, "gpd_quantile requires a positive scale");
assert!((0.0..1.0).contains(&p), "gpd_quantile requires p in [0, 1)");
if xi.abs() < SHAPE_TOL {
-sigma * (1.0 - p).ln()
} else {
sigma * ((1.0 - p).powf(-xi) - 1.0) / xi
}
}
pub fn gpd_fit(exceedances: &[f64]) -> Result<(f64, f64), GeomError> {
if exceedances.len() < 10 {
return Err(GeomError::InvalidArgument("gpd_fit requires at least ten exceedances"));
}
if exceedances.iter().any(|&y| !(y > 0.0)) {
return Err(GeomError::InvalidArgument("gpd_fit requires positive exceedances"));
}
let n = exceedances.len() as f64;
let mean: f64 = exceedances.iter().sum::<f64>() / n;
let nll = |sigma: f64, xi: f64| -> f64 {
if !(sigma > 0.0) || !sigma.is_finite() {
return f64::MAX;
}
if xi.abs() < SHAPE_TOL {
let acc = n * sigma.ln() + exceedances.iter().sum::<f64>() / sigma;
return if acc.is_finite() { acc } else { f64::MAX };
}
let mut acc = n * sigma.ln();
for &y in exceedances {
let s = 1.0 + xi * y / sigma;
if s <= 1e-300 {
return f64::MAX;
}
acc += (1.0 / xi + 1.0) * s.ln();
}
if acc.is_finite() {
acc
} else {
f64::MAX
}
};
let objective = |p: &[f64]| -> f64 { nll(p[0].clamp(-40.0, 40.0).exp(), p[1]) };
let best = crate::optimization::nelder_mead(&objective, &[mean.ln(), 0.05], 0.2, 1e-12, 4000);
let (sigma, xi) = (best[0].clamp(-40.0, 40.0).exp(), best[1]);
if !(sigma > 0.0) || !xi.is_finite() || nll(sigma, xi) >= f64::MAX {
return Err(GeomError::Degenerate("gpd_fit: no feasible parameters found"));
}
Ok((sigma, xi))
}
#[must_use]
pub fn mean_residual_life(x: &[f64], thresholds: &[f64]) -> Vec<f64> {
thresholds
.iter()
.map(|&u| {
let excesses: Vec<f64> = x.iter().filter(|&&v| v > u).map(|&v| v - u).collect();
if excesses.is_empty() {
f64::NAN
} else {
excesses.iter().sum::<f64>() / excesses.len() as f64
}
})
.collect()
}
#[must_use]
pub fn hill_estimator(x: &[f64], k: usize) -> f64 {
assert!(k >= 1, "hill_estimator requires k >= 1");
assert!(k < x.len(), "hill_estimator requires k < n");
let mut sorted = x.to_vec();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
assert!(
sorted[k] > 0.0,
"hill_estimator requires the top k + 1 observations to be positive"
);
let anchor = sorted[k].ln();
(0..k).map(|i| sorted[i].ln() - anchor).sum::<f64>() / k as f64
}
#[must_use]
pub fn return_level(mu: f64, sigma: f64, xi: f64, period: f64) -> f64 {
assert!(period > 1.0, "return_level requires a period above one");
gev_quantile(1.0 - 1.0 / period, mu, sigma, xi)
}
#[must_use]
pub fn return_period(mu: f64, sigma: f64, xi: f64, level: f64) -> f64 {
let p = gev_cdf(level, mu, sigma, xi);
if p >= 1.0 {
f64::INFINITY
} else {
1.0 / (1.0 - p)
}
}
#[must_use]
pub fn block_maxima(x: &[f64], block: usize) -> Vec<f64> {
assert!(block > 0, "block_maxima requires a positive block size");
x.chunks_exact(block)
.map(|c| c.iter().copied().fold(f64::NEG_INFINITY, f64::max))
.collect()
}
#[must_use]
pub fn extremal_index(x: &[f64], threshold: f64) -> f64 {
assert!(!x.is_empty(), "extremal_index requires observations");
let positions: Vec<usize> =
x.iter().enumerate().filter(|(_, &v)| v > threshold).map(|(i, _)| i).collect();
let n = positions.len();
if n < 3 {
return 1.0;
}
let gaps: Vec<f64> =
positions.windows(2).map(|w| (w[1] - w[0]) as f64).collect();
let count = (n - 1) as f64;
let max_gap = gaps.iter().copied().fold(0.0f64, f64::max);
let theta = if max_gap <= 2.0 {
let s: f64 = gaps.iter().sum();
let ss: f64 = gaps.iter().map(|t| t * t).sum();
if ss <= 0.0 {
return 1.0;
}
2.0 * s * s / (count * ss)
} else {
let s: f64 = gaps.iter().map(|t| t - 1.0).sum();
let ss: f64 = gaps.iter().map(|t| (t - 1.0) * (t - 2.0)).sum();
if ss <= 0.0 {
return 1.0;
}
2.0 * s * s / (count * ss)
};
theta.clamp(0.0, 1.0)
}
#[must_use]
pub fn kendall_tau(x: &[f64], y: &[f64]) -> f64 {
assert!(x.len() == y.len(), "kendall_tau requires equal lengths");
assert!(x.len() >= 2, "kendall_tau requires at least two points");
let n = x.len();
let pairs = (n * (n - 1) / 2) as i64;
if pairs <= 0 {
return 0.0;
}
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
x[a].partial_cmp(&x[b])
.unwrap_or(std::cmp::Ordering::Equal)
.then(y[a].partial_cmp(&y[b]).unwrap_or(std::cmp::Ordering::Equal))
});
let tied = |v: &[f64]| -> i64 {
let mut sorted = v.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut acc = 0i64;
let mut run = 1i64;
for i in 1..sorted.len() {
if sorted[i] == sorted[i - 1] {
run += 1;
} else {
acc += run * (run - 1) / 2;
run = 1;
}
}
acc + run * (run - 1) / 2
};
let tied_x = tied(x);
let tied_y = tied(y);
let mut tied_both = 0i64;
let mut run = 1i64;
for i in 1..n {
let (a, b) = (order[i], order[i - 1]);
if x[a] == x[b] && y[a] == y[b] {
run += 1;
} else {
tied_both += run * (run - 1) / 2;
run = 1;
}
}
tied_both += run * (run - 1) / 2;
let sequence: Vec<f64> = order.iter().map(|&i| y[i]).collect();
let discordant = count_inversions(&sequence);
let comparable = pairs - tied_x - tied_y + tied_both;
(comparable - 2 * discordant) as f64 / pairs as f64
}
fn count_inversions(v: &[f64]) -> i64 {
let mut work = v.to_vec();
let mut buffer = work.clone();
merge_count(&mut work, &mut buffer, 0, v.len())
}
fn merge_count(v: &mut [f64], buffer: &mut [f64], lo: usize, hi: usize) -> i64 {
if hi - lo < 2 {
return 0;
}
let mid = lo + (hi - lo) / 2;
let mut count = merge_count(v, buffer, lo, mid) + merge_count(v, buffer, mid, hi);
let (mut i, mut j, mut k) = (lo, mid, lo);
while i < mid && j < hi {
if v[i] <= v[j] {
buffer[k] = v[i];
i += 1;
} else {
count += (mid - i) as i64;
buffer[k] = v[j];
j += 1;
}
k += 1;
}
while i < mid {
buffer[k] = v[i];
i += 1;
k += 1;
}
while j < hi {
buffer[k] = v[j];
j += 1;
k += 1;
}
v[lo..hi].copy_from_slice(&buffer[lo..hi]);
count
}
fn ranks(x: &[f64]) -> Vec<f64> {
let n = x.len();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal));
let mut out = vec![0.0; n];
let mut i = 0usize;
while i < n {
let mut j = i;
while j + 1 < n && x[order[j + 1]] == x[order[i]] {
j += 1;
}
let average = ((i + j) as f64) / 2.0 + 1.0;
for &k in &order[i..=j] {
out[k] = average;
}
i = j + 1;
}
out
}
#[must_use]
pub fn spearman_rho(x: &[f64], y: &[f64]) -> f64 {
assert!(x.len() == y.len(), "spearman_rho requires equal lengths");
assert!(x.len() >= 2, "spearman_rho requires at least two points");
let (rx, ry) = (ranks(x), ranks(y));
let n = x.len() as f64;
let (mx, my) = (rx.iter().sum::<f64>() / n, ry.iter().sum::<f64>() / n);
let mut num = 0.0;
let mut dx = 0.0;
let mut dy = 0.0;
for i in 0..x.len() {
let (a, b) = (rx[i] - mx, ry[i] - my);
num += a * b;
dx += a * a;
dy += b * b;
}
if dx <= 0.0 || dy <= 0.0 {
0.0
} else {
num / (dx * dy).sqrt()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CopulaFamily {
Gaussian,
Clayton,
Gumbel,
Frank,
}
pub fn copula_gaussian_sample(
corr: &Matrix,
n: usize,
rng: &mut Rng,
) -> Result<Vec<Vec<f64>>, GeomError> {
let l = correlation_factor(corr)?;
let d = corr.rows;
let normal = Normal::new(0.0, 1.0);
Ok((0..n)
.map(|_| {
let z: Vec<f64> = (0..d).map(|_| rng.next_gaussian()).collect();
(0..d)
.map(|i| {
let v: f64 = (0..=i).map(|j| l.get(i, j) * z[j]).sum();
normal.cdf(v)
})
.collect()
})
.collect())
}
pub fn copula_t_sample(
corr: &Matrix,
df: f64,
n: usize,
rng: &mut Rng,
) -> Result<Vec<Vec<f64>>, GeomError> {
if !(df >= 1.0) {
return Err(GeomError::InvalidArgument("copula_t_sample requires df >= 1"));
}
let l = correlation_factor(corr)?;
let d = corr.rows;
let t = StudentT::new(df);
let k = df.round().max(1.0) as usize;
Ok((0..n)
.map(|_| {
let z: Vec<f64> = (0..d).map(|_| rng.next_gaussian()).collect();
let chi: f64 = (0..k).map(|_| rng.next_gaussian().powi(2)).sum();
let scale = (df / chi.max(1e-300)).sqrt();
(0..d)
.map(|i| {
let v: f64 = (0..=i).map(|j| l.get(i, j) * z[j]).sum();
t.cdf(v * scale)
})
.collect()
})
.collect())
}
fn correlation_factor(corr: &Matrix) -> Result<Matrix, GeomError> {
if !corr.is_square() || corr.rows == 0 {
return Err(GeomError::InvalidArgument("copula requires a square correlation matrix"));
}
if !corr.is_symmetric(1e-9) {
return Err(GeomError::InvalidArgument("copula requires a symmetric correlation matrix"));
}
for i in 0..corr.rows {
if (corr.get(i, i) - 1.0).abs() > 1e-9 {
return Err(GeomError::InvalidArgument("a correlation matrix has unit diagonal"));
}
}
cholesky(corr).map_err(|_| GeomError::Degenerate("the correlation matrix is not positive definite"))
}
#[must_use]
pub fn copula_clayton(theta: f64, n: usize, rng: &mut Rng) -> Vec<Vec<f64>> {
assert!(theta > 0.0, "copula_clayton requires theta > 0");
(0..n)
.map(|_| {
let u = rng.next_f64().clamp(1e-12, 1.0 - 1e-12);
let w = rng.next_f64().clamp(1e-12, 1.0 - 1e-12);
let v = (u.powf(-theta) * (w.powf(-theta / (1.0 + theta)) - 1.0) + 1.0)
.powf(-1.0 / theta);
vec![u, v.clamp(0.0, 1.0)]
})
.collect()
}
#[must_use]
pub fn copula_gumbel(theta: f64, n: usize, rng: &mut Rng) -> Vec<Vec<f64>> {
assert!(theta >= 1.0, "copula_gumbel requires theta >= 1");
if (theta - 1.0).abs() < SHAPE_TOL {
return (0..n).map(|_| vec![rng.next_f64(), rng.next_f64()]).collect();
}
let alpha = 1.0 / theta;
(0..n)
.map(|_| {
let s = positive_stable(alpha, rng);
let e1 = -rng.next_f64().max(1e-300).ln();
let e2 = -rng.next_f64().max(1e-300).ln();
vec![
(-(e1 / s).powf(alpha)).exp().clamp(0.0, 1.0),
(-(e2 / s).powf(alpha)).exp().clamp(0.0, 1.0),
]
})
.collect()
}
fn positive_stable(alpha: f64, rng: &mut Rng) -> f64 {
let u = rng.next_f64().clamp(1e-12, 1.0 - 1e-12) * std::f64::consts::PI;
let w = -rng.next_f64().max(1e-300).ln();
let a = (alpha * u).sin() / u.sin().powf(1.0 / alpha);
let b = ((1.0 - alpha) * u).sin() / w;
a * b.powf((1.0 - alpha) / alpha)
}
#[must_use]
pub fn copula_frank(theta: f64, n: usize, rng: &mut Rng) -> Vec<Vec<f64>> {
assert!(theta.abs() > SHAPE_TOL, "copula_frank requires a non-zero theta");
let a = (-theta).exp() - 1.0;
(0..n)
.map(|_| {
let u = rng.next_f64().clamp(1e-12, 1.0 - 1e-12);
let w = rng.next_f64().clamp(1e-12, 1.0 - 1e-12);
let eu = (-theta * u).exp();
let denominator = eu - w * (eu - 1.0);
let v = -(1.0 + w * a / denominator).ln() / theta;
vec![u, v.clamp(0.0, 1.0)]
})
.collect()
}
fn debye1(theta: f64) -> f64 {
if theta.abs() < 1e-8 {
return 1.0 - theta / 4.0;
}
let steps = 4000usize;
let h = theta / steps as f64;
let mut acc = 0.0;
for k in 0..steps {
let t = (k as f64 + 0.5) * h;
let d = t.exp() - 1.0;
acc += if d.abs() < 1e-12 { 1.0 } else { t / d } * h;
}
acc / theta
}
#[must_use]
pub fn copula_tau(family: CopulaFamily, theta: f64) -> f64 {
match family {
CopulaFamily::Gaussian => 2.0 * theta.clamp(-1.0, 1.0).asin() / std::f64::consts::PI,
CopulaFamily::Clayton => theta / (theta + 2.0),
CopulaFamily::Gumbel => 1.0 - 1.0 / theta,
CopulaFamily::Frank => {
if theta.abs() < 1e-8 {
0.0
} else {
1.0 - 4.0 * (1.0 - debye1(theta)) / theta
}
}
}
}
pub fn copula_fit_tau(data: &[Vec<f64>], family: CopulaFamily) -> Result<f64, GeomError> {
if data.len() < 3 || data.iter().any(|r| r.len() != 2) {
return Err(GeomError::InvalidArgument("copula_fit_tau requires at least three pairs"));
}
let x: Vec<f64> = data.iter().map(|r| r[0]).collect();
let y: Vec<f64> = data.iter().map(|r| r[1]).collect();
let tau = kendall_tau(&x, &y);
match family {
CopulaFamily::Gaussian => Ok((std::f64::consts::PI * tau / 2.0).sin()),
CopulaFamily::Clayton => {
if tau <= 0.0 || tau >= 1.0 {
return Err(GeomError::InvalidArgument(
"Clayton represents only positive dependence",
));
}
Ok(2.0 * tau / (1.0 - tau))
}
CopulaFamily::Gumbel => {
if tau <= 0.0 || tau >= 1.0 {
return Err(GeomError::InvalidArgument(
"Gumbel represents only positive dependence",
));
}
Ok(1.0 / (1.0 - tau))
}
CopulaFamily::Frank => {
if tau.abs() < 1e-9 {
return Err(GeomError::InvalidArgument("Frank is undefined at zero dependence"));
}
let (mut lo, mut hi) = if tau > 0.0 { (1e-6, 200.0) } else { (-200.0, -1e-6) };
if (copula_tau(family, lo) - tau).signum() == (copula_tau(family, hi) - tau).signum() {
return Err(GeomError::InvalidArgument("tau is outside Frank's range"));
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if (copula_tau(family, mid) - tau).signum()
== (copula_tau(family, lo) - tau).signum()
{
lo = mid;
} else {
hi = mid;
}
}
Ok(0.5 * (lo + hi))
}
}
}
pub fn empirical_copula(data: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, GeomError> {
if data.is_empty() {
return Err(GeomError::Empty);
}
let d = data[0].len();
if d == 0 || data.iter().any(|r| r.len() != d) {
return Err(GeomError::InvalidArgument("empirical_copula requires rectangular data"));
}
let n = data.len();
let scale = (n + 1) as f64;
let columns: Vec<Vec<f64>> = (0..d)
.map(|j| ranks(&data.iter().map(|r| r[j]).collect::<Vec<f64>>()))
.collect();
Ok((0..n).map(|i| (0..d).map(|j| columns[j][i] / scale).collect()).collect())
}
pub fn tail_dependence_coefficient(
data: &[Vec<f64>],
q: f64,
) -> Result<(f64, f64), GeomError> {
if !(q > 0.0 && q < 1.0) {
return Err(GeomError::InvalidArgument("tail dependence requires q in (0, 1)"));
}
if data.len() < 4 || data.iter().any(|r| r.len() != 2) {
return Err(GeomError::InvalidArgument("tail dependence requires at least four pairs"));
}
let pseudo = empirical_copula(data)?;
let n = pseudo.len() as f64;
let both_below = pseudo.iter().filter(|r| r[0] <= q && r[1] <= q).count() as f64 / n;
let first_below = pseudo.iter().filter(|r| r[0] <= q).count() as f64 / n;
let both_above = pseudo.iter().filter(|r| r[0] > q && r[1] > q).count() as f64 / n;
let first_above = pseudo.iter().filter(|r| r[0] > q).count() as f64 / n;
let lower = if first_below > 0.0 { both_below / first_below } else { 0.0 };
let upper = if first_above > 0.0 { both_above / first_above } else { 0.0 };
Ok((lower, upper))
}
pub fn pickands_dependence(data: &[Vec<f64>], t: f64) -> Result<f64, GeomError> {
if !(t > 0.0 && t < 1.0) {
return Err(GeomError::InvalidArgument("pickands_dependence requires t in (0, 1)"));
}
if data.len() < 4 || data.iter().any(|r| r.len() != 2) {
return Err(GeomError::InvalidArgument("pickands_dependence requires at least four pairs"));
}
let pseudo = empirical_copula(data)?;
let n = pseudo.len() as f64;
let mut acc = 0.0;
for row in &pseudo {
let xi = -row[0].ln();
let eta = -row[1].ln();
acc += (xi / (1.0 - t)).min(eta / t);
}
let mean = acc / n;
if !(mean > 0.0) {
return Err(GeomError::Degenerate("pickands_dependence: degenerate sample"));
}
Ok((1.0 / mean).clamp(t.max(1.0 - t), 1.0))
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol * (1.0 + a.abs().max(b.abs()))
}
fn gev_sample(n: usize, mu: f64, sigma: f64, xi: f64, rng: &mut Rng) -> Vec<f64> {
(0..n)
.map(|_| gev_quantile(rng.next_f64().clamp(1e-12, 1.0 - 1e-12), mu, sigma, xi))
.collect()
}
fn gpd_sample(n: usize, sigma: f64, xi: f64, rng: &mut Rng) -> Vec<f64> {
(0..n)
.map(|_| gpd_quantile(rng.next_f64().clamp(0.0, 1.0 - 1e-12), sigma, xi))
.collect()
}
#[test]
fn the_gev_distribution_function_is_the_integral_of_its_density() {
for &(mu, sigma, xi) in
&[(0.0, 1.0, 0.0), (2.0, 1.5, 0.3), (-1.0, 0.7, -0.25), (0.0, 1.0, 0.8)]
{
let lo = gev_quantile(1e-9, mu, sigma, xi);
for &p in &[0.05f64, 0.25, 0.5, 0.9, 0.99] {
let x = gev_quantile(p, mu, sigma, xi);
let steps = 200_000usize;
let h = (x - lo) / steps as f64;
let mass: f64 =
(0..steps).map(|k| gev_pdf(lo + (k as f64 + 0.5) * h, mu, sigma, xi) * h).sum();
assert!(
(mass - p).abs() < 1e-5,
"xi = {xi}, p = {p}: integrated {mass} against cdf {}",
gev_cdf(x, mu, sigma, xi)
);
assert!(
(gev_cdf(x, mu, sigma, xi) - p).abs() < 1e-12,
"xi = {xi}: the quantile and cdf disagree at p = {p}"
);
}
assert!(gev_pdf(lo - 1e6, mu, sigma, xi) >= 0.0);
}
}
#[test]
fn the_sign_of_the_shape_decides_whether_the_tail_is_bounded() {
let (mu, sigma, xi) = (0.0f64, 1.0f64, -0.5f64);
let endpoint = mu - sigma / xi;
assert!((endpoint - 2.0).abs() < 1e-12, "the endpoint is {endpoint}");
assert_eq!(gev_cdf(endpoint + 1e-9, mu, sigma, xi), 1.0);
assert_eq!(gev_pdf(endpoint + 1e-9, mu, sigma, xi), 0.0);
assert!(gev_cdf(endpoint - 1e-6, mu, sigma, xi) < 1.0);
assert!(return_period(mu, sigma, xi, endpoint + 1.0).is_infinite());
let heavy = (0.0, 1.0, 0.5);
let hundred = return_level(heavy.0, heavy.1, heavy.2, 100.0);
let thousand = return_level(heavy.0, heavy.1, heavy.2, 1000.0);
assert!(thousand > 2.5 * hundred, "{thousand} is not far past {hundred}");
let light = (0.0, 1.0, 0.0);
let l100 = return_level(light.0, light.1, light.2, 100.0);
let l1000 = return_level(light.0, light.1, light.2, 1000.0);
assert!(l1000 < 1.6 * l100, "an exponential tail grew too fast");
}
#[test]
fn return_level_and_return_period_invert_each_other() {
for &(mu, sigma, xi) in &[(10.0, 2.0, 0.0), (0.0, 1.0, 0.25), (5.0, 3.0, -0.2)] {
for &period in &[2.0f64, 10.0, 50.0, 100.0, 500.0] {
let level = return_level(mu, sigma, xi, period);
let back = return_period(mu, sigma, xi, level);
assert!(
close(back, period, 1e-9),
"xi = {xi}: period {period} became {back} through level {level}"
);
}
let levels: Vec<f64> =
[2.0f64, 5.0, 20.0, 100.0].iter().map(|&t| return_level(mu, sigma, xi, t)).collect();
assert!(levels.windows(2).all(|w| w[1] > w[0]), "return levels are not increasing");
}
}
#[test]
fn gev_fitting_recovers_the_parameters_it_sampled_from() {
for &(mu, sigma, xi) in &[(0.0f64, 1.0f64, 0.0f64), (3.0, 2.0, 0.3), (1.0, 1.0, -0.25)] {
let mut rng = Rng::new(0x06E7_0001 + (xi.abs() * 1000.0) as u64);
let sample = gev_sample(4000, mu, sigma, xi, &mut rng);
let (m, s, x) = gev_fit(&sample).unwrap();
assert!((m - mu).abs() < 0.12, "location {m} against {mu}");
assert!(close(s, sigma, 0.10), "scale {s} against {sigma}");
assert!((x - xi).abs() < 0.08, "shape {x} against {xi}");
}
}
#[test]
fn a_gumbel_fit_is_the_shape_zero_member_of_the_gev_family() {
let mut rng = Rng::new(0x06E7_0002);
let sample = gev_sample(3000, 5.0, 2.0, 0.0, &mut rng);
let (gm, gs) = gumbel_fit(&sample).unwrap();
assert!((gm - 5.0).abs() < 0.15, "location {gm}");
assert!(close(gs, 2.0, 0.08), "scale {gs}");
let (fm, fs, fx) = gev_fit(&sample).unwrap();
assert!(fx.abs() < 0.06, "the free shape came out {fx}");
let restricted = gev_nll(&sample, gm, gs, 0.0);
let free = gev_nll(&sample, fm, fs, fx);
assert!(
free <= restricted + 1e-6,
"the free fit ({free}) was worse than the restricted one ({restricted})"
);
assert!(restricted - free < 5.0, "the shape parameter bought {}", restricted - free);
}
#[test]
fn gev_fitting_rejects_input_it_cannot_use() {
assert!(gev_fit(&[1.0; 5]).is_err());
assert!(gev_fit(&[3.0; 40]).is_err());
assert!(gumbel_fit(&[1.0, 2.0]).is_err());
assert!(gumbel_fit(&[7.0; 40]).is_err());
}
#[test]
fn the_generalised_pareto_is_a_distribution_and_inverts_its_own_quantile() {
for &(sigma, xi) in &[(1.0, 0.0), (2.0, 0.4), (1.5, -0.3)] {
for &p in &[0.1f64, 0.5, 0.9, 0.99] {
let y = gpd_quantile(p, sigma, xi);
assert!((gpd_cdf(y, sigma, xi) - p).abs() < 1e-12, "xi = {xi}, p = {p}");
}
let top = gpd_quantile(0.999, sigma, xi);
let steps = 400_000usize;
let h = top / steps as f64;
let mass: f64 = (0..steps).map(|k| gpd_pdf((k as f64 + 0.5) * h, sigma, xi) * h).sum();
assert!((mass - 0.999).abs() < 1e-5, "xi = {xi} integrated to {mass}");
assert_eq!(gpd_cdf(-1.0, sigma, xi), 0.0);
assert_eq!(gpd_pdf(-1.0, sigma, xi), 0.0);
}
let (sigma, xi) = (1.0, -0.5);
assert_eq!(gpd_cdf(2.0 + 1e-9, sigma, xi), 1.0);
assert_eq!(gpd_pdf(2.0 + 1e-9, sigma, xi), 0.0);
}
#[test]
fn gpd_fitting_recovers_the_parameters_it_sampled_from() {
for &(sigma, xi) in &[(1.0f64, 0.0f64), (2.0, 0.3), (1.0, -0.2)] {
let mut rng = Rng::new(0x06D0_0001 + (xi.abs() * 1000.0) as u64);
let sample = gpd_sample(5000, sigma, xi, &mut rng);
let (s, x) = gpd_fit(&sample).unwrap();
assert!(close(s, sigma, 0.10), "scale {s} against {sigma}");
assert!((x - xi).abs() < 0.06, "shape {x} against {xi}");
}
assert!(gpd_fit(&[1.0; 5]).is_err());
assert!(gpd_fit(&[1.0, -2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0]).is_err());
}
#[test]
fn block_maxima_and_threshold_exceedances_find_the_same_shape() {
let xi = 0.3f64;
let mut rng = Rng::new(0xB07A_0011);
let raw: Vec<f64> = (0..40_000)
.map(|_| rng.next_f64().clamp(1e-12, 1.0 - 1e-12).powf(-xi))
.collect();
let maxima = block_maxima(&raw, 200);
assert_eq!(maxima.len(), 200);
let (_, _, block_shape) = gev_fit(&maxima).unwrap();
let mut sorted = raw.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let threshold = sorted[raw.len() - 2000];
let excesses: Vec<f64> =
raw.iter().filter(|&&v| v > threshold).map(|&v| v - threshold).collect();
let (_, pot_shape) = gpd_fit(&excesses).unwrap();
assert!((block_shape - xi).abs() < 0.12, "the block route gave {block_shape}");
assert!((pot_shape - xi).abs() < 0.08, "the threshold route gave {pot_shape}");
assert!(
(block_shape - pot_shape).abs() < 0.15,
"the two routes disagree: {block_shape} against {pot_shape}"
);
}
#[test]
fn block_maxima_drops_a_partial_trailing_block() {
let x: Vec<f64> = (0..23).map(|i| i as f64).collect();
let m = block_maxima(&x, 5);
assert_eq!(m, vec![4.0, 9.0, 14.0, 19.0]);
assert_eq!(block_maxima(&x, 30), Vec::<f64>::new());
assert_eq!(block_maxima(&x, 1).len(), 23);
}
#[test]
fn the_mean_excess_is_linear_in_the_threshold_for_pareto_tails() {
let (sigma, xi) = (1.0, 0.25f64);
let mut rng = Rng::new(0x06D0_11FE);
let sample = gpd_sample(200_000, sigma, xi, &mut rng);
let thresholds: Vec<f64> = (0..8).map(|k| k as f64 * 0.5).collect();
let excess = mean_residual_life(&sample, &thresholds);
for (u, e) in thresholds.iter().zip(&excess) {
let expected = (sigma + xi * u) / (1.0 - xi);
assert!(close(*e, expected, 0.06), "at u = {u} the mean excess is {e}, not {expected}");
}
let slope = (excess[7] - excess[0]) / (thresholds[7] - thresholds[0]);
assert!(
close(slope, xi / (1.0 - xi), 0.10),
"the slope is {slope}, not {}",
xi / (1.0 - xi)
);
let mut rng = Rng::new(0x06D0_11F0);
let exponential = gpd_sample(200_000, 1.0, 0.0, &mut rng);
let flat = mean_residual_life(&exponential, &thresholds);
for e in &flat {
assert!(close(*e, 1.0, 0.06), "an exponential mean excess came out {e}");
}
assert!(mean_residual_life(&exponential, &[1e9])[0].is_nan());
}
#[test]
fn the_hill_estimator_recovers_a_power_law_index() {
for &alpha in &[1.5f64, 2.0, 4.0] {
let mut rng = Rng::new(0x41BB_0000 + (alpha * 10.0) as u64);
let sample: Vec<f64> = (0..40_000)
.map(|_| rng.next_f64().clamp(1e-12, 1.0 - 1e-12).powf(-1.0 / alpha))
.collect();
let estimate = hill_estimator(&sample, 2000);
assert!(
close(estimate, 1.0 / alpha, 0.10),
"alpha = {alpha}: Hill gave {estimate}, not {}",
1.0 / alpha
);
}
let mut rng = Rng::new(0x41BB_0002);
let sample: Vec<f64> =
(0..40_000).map(|_| rng.next_f64().clamp(1e-12, 1.0 - 1e-12).powf(-0.5)).collect();
for &k in &[500usize, 2000, 8000] {
let e = hill_estimator(&sample, k);
assert!(e > 0.0 && e.is_finite(), "k = {k} gave {e}");
assert!((e - 0.5).abs() < 0.12, "k = {k} gave {e}");
}
}
#[test]
fn the_extremal_index_separates_clustered_exceedances_from_isolated_ones() {
let mut rng = Rng::new(0x0E27_0001);
let independent: Vec<f64> = (0..20_000).map(|_| rng.next_gaussian()).collect();
let threshold = 2.0;
let solo = extremal_index(&independent, threshold);
assert!(solo > 0.85, "independent exceedances gave an index of {solo}");
assert!(solo <= 1.0);
for m in [2usize, 4] {
let mut rng = Rng::new(0x0E27_0002 + m as u64);
let base: Vec<f64> = (0..40_000).map(|_| rng.next_gaussian()).collect();
let clustered: Vec<f64> = (m - 1..base.len())
.map(|t| base[t + 1 - m..=t].iter().copied().fold(f64::NEG_INFINITY, f64::max))
.collect();
let index = extremal_index(&clustered, 2.2);
assert!(
(index - 1.0 / m as f64).abs() < 0.18,
"a window of {m} gave an index of {index}, not {}",
1.0 / m as f64
);
assert!(index < solo, "clustering did not lower the index");
}
assert_eq!(extremal_index(&[0.0, 0.0, 0.0, 5.0], 1.0), 1.0);
}
#[test]
fn rank_correlations_are_invariant_to_monotone_transformation() {
let mut rng = Rng::new(0x002A_0001);
let x: Vec<f64> = (0..300).map(|_| rng.next_gaussian()).collect();
let y: Vec<f64> = x.iter().map(|v| 0.6 * v + 0.8 * rng.next_gaussian()).collect();
let (tau, rho) = (kendall_tau(&x, &y), spearman_rho(&x, &y));
for f in [
(|v: f64| v.exp()) as fn(f64) -> f64,
(|v: f64| v * 3.0 + 7.0) as fn(f64) -> f64,
(|v: f64| v.tanh()) as fn(f64) -> f64,
] {
let fx: Vec<f64> = x.iter().map(|&v| f(v)).collect();
let fy: Vec<f64> = y.iter().map(|&v| f(v)).collect();
assert!((kendall_tau(&fx, &fy) - tau).abs() < 1e-12, "tau moved under a transform");
assert!((spearman_rho(&fx, &fy) - rho).abs() < 1e-9, "rho moved under a transform");
}
assert!(tau > 0.0 && rho > 0.0);
assert!(tau.abs() <= 1.0 && rho.abs() <= 1.0);
assert!(rho > tau, "rho {rho} did not exceed tau {tau}");
}
#[test]
fn the_fast_kendall_agrees_with_comparing_every_pair() {
let direct = |x: &[f64], y: &[f64]| -> f64 {
let n = x.len();
let (mut c, mut d) = (0i64, 0i64);
for i in 0..n {
for j in i + 1..n {
use std::cmp::Ordering::Equal;
let a = x[j].partial_cmp(&x[i]).unwrap();
let b = y[j].partial_cmp(&y[i]).unwrap();
if a == Equal || b == Equal {
continue;
}
if a == b {
c += 1;
} else {
d += 1;
}
}
}
(c - d) as f64 / (n * (n - 1) / 2) as f64
};
let mut rng = Rng::new(0x002A_FA57);
for round in 0..40 {
let n = 12 + round * 3;
let grid = 1.0 + (round % 5) as f64 * 2.0;
let x: Vec<f64> = (0..n).map(|_| (rng.next_gaussian() * grid).round()).collect();
let y: Vec<f64> = x
.iter()
.map(|v| ((0.7 * v + rng.next_gaussian()) * grid).round())
.collect();
let fast = kendall_tau(&x, &y);
let slow = direct(&x, &y);
assert!(
(fast - slow).abs() < 1e-12,
"round {round}: merge count gave {fast}, pair count {slow}"
);
assert!(fast.abs() <= 1.0 + 1e-12);
}
assert_eq!(kendall_tau(&[1.0, 2.0, 3.0, 4.0], &[5.0; 4]), 0.0);
assert_eq!(kendall_tau(&[2.0; 6], &[9.0; 6]), 0.0);
}
#[test]
fn perfect_and_reversed_orderings_hit_the_ends_of_the_range() {
let x: Vec<f64> = (0..50).map(|i| i as f64).collect();
let up: Vec<f64> = x.iter().map(|v| v * 2.0 + 1.0).collect();
let down: Vec<f64> = x.iter().map(|v| -v).collect();
assert!((kendall_tau(&x, &up) - 1.0).abs() < 1e-12);
assert!((spearman_rho(&x, &up) - 1.0).abs() < 1e-12);
assert!((kendall_tau(&x, &down) + 1.0).abs() < 1e-12);
assert!((spearman_rho(&x, &down) + 1.0).abs() < 1e-12);
assert_eq!(kendall_tau(&x, &vec![4.0; 50]), 0.0);
assert_eq!(spearman_rho(&x, &vec![4.0; 50]), 0.0);
let tied = [1.0, 2.0, 2.0, 4.0];
assert_eq!(ranks(&tied), vec![1.0, 2.5, 2.5, 4.0]);
}
#[test]
fn every_copula_sampler_produces_uniform_margins() {
let mut rng = Rng::new(0x00C0_0001);
let corr = Matrix::from_rows(&[&[1.0, 0.6], &[0.6, 1.0]]).unwrap();
let samples: Vec<(&str, Vec<Vec<f64>>)> = vec![
("gaussian", copula_gaussian_sample(&corr, 20_000, &mut rng).unwrap()),
("t", copula_t_sample(&corr, 4.0, 20_000, &mut rng).unwrap()),
("clayton", copula_clayton(2.0, 20_000, &mut rng)),
("gumbel", copula_gumbel(2.0, 20_000, &mut rng)),
("frank", copula_frank(5.0, 20_000, &mut rng)),
];
for (name, data) in samples {
assert_eq!(data.len(), 20_000);
for j in 0..2 {
let column: Vec<f64> = data.iter().map(|r| r[j]).collect();
assert!(
column.iter().all(|&v| (0.0..=1.0).contains(&v)),
"{name} margin {j} left the unit interval"
);
let mean: f64 = column.iter().sum::<f64>() / column.len() as f64;
assert!(close(mean, 0.5, 0.03), "{name} margin {j} has mean {mean}");
let var: f64 = column.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>()
/ column.len() as f64;
assert!(close(var, 1.0 / 12.0, 0.05), "{name} margin {j} has variance {var}");
for d in 0..10 {
let lo = d as f64 / 10.0;
let share = column.iter().filter(|&&v| v >= lo && v < lo + 0.1).count() as f64
/ column.len() as f64;
assert!(close(share, 0.1, 0.10), "{name} decile {d} holds {share}");
}
}
}
}
#[test]
fn kendall_tau_inversion_recovers_the_parameter_that_generated_the_sample() {
let mut rng = Rng::new(0x00C0_0002);
for &theta in &[1.5f64, 3.0, 6.0] {
let data = copula_clayton(theta, 30_000, &mut rng);
let fitted = copula_fit_tau(&data, CopulaFamily::Clayton).unwrap();
assert!(close(fitted, theta, 0.10), "Clayton {theta} came back as {fitted}");
}
for &theta in &[1.5f64, 2.5, 5.0] {
let data = copula_gumbel(theta, 30_000, &mut rng);
let fitted = copula_fit_tau(&data, CopulaFamily::Gumbel).unwrap();
assert!(close(fitted, theta, 0.10), "Gumbel {theta} came back as {fitted}");
}
for &theta in &[2.0f64, 8.0, -5.0] {
let data = copula_frank(theta, 30_000, &mut rng);
let fitted = copula_fit_tau(&data, CopulaFamily::Frank).unwrap();
assert!(close(fitted, theta, 0.12), "Frank {theta} came back as {fitted}");
}
for &rho in &[0.3f64, 0.7, -0.5] {
let corr = Matrix::from_rows(&[&[1.0, rho], &[rho, 1.0]]).unwrap();
let data = copula_gaussian_sample(&corr, 30_000, &mut rng).unwrap();
let fitted = copula_fit_tau(&data, CopulaFamily::Gaussian).unwrap();
assert!(close(fitted, rho, 0.06), "Gaussian {rho} came back as {fitted}");
}
}
#[test]
fn the_tau_relations_are_monotone_and_span_the_dependence_range() {
assert!((copula_tau(CopulaFamily::Clayton, 1e-9)).abs() < 1e-8);
assert!((copula_tau(CopulaFamily::Gumbel, 1.0)).abs() < 1e-12);
assert!((copula_tau(CopulaFamily::Gaussian, 0.0)).abs() < 1e-12);
assert!((copula_tau(CopulaFamily::Frank, 0.0)).abs() < 1e-12);
assert!((copula_tau(CopulaFamily::Gaussian, 1.0) - 1.0).abs() < 1e-12);
let mut previous = -2.0;
for k in 1..60 {
let t = copula_tau(CopulaFamily::Clayton, k as f64 * 0.5);
assert!(t > previous, "Clayton tau is not increasing at {k}");
assert!((0.0..1.0).contains(&t));
previous = t;
}
let mut previous = -2.0;
for k in 0..60 {
let t = copula_tau(CopulaFamily::Frank, -20.0 + k as f64 * 0.7);
assert!(t > previous, "Frank tau is not increasing at {k}");
assert!((-1.0..1.0).contains(&t));
previous = t;
}
for &theta in &[1.0f64, 4.0, 12.0] {
assert!(
(copula_tau(CopulaFamily::Frank, theta)
+ copula_tau(CopulaFamily::Frank, -theta))
.abs()
< 1e-6,
"Frank tau is not odd at {theta}"
);
}
}
#[test]
fn tail_dependence_separates_the_families_where_correlation_cannot() {
let mut rng = Rng::new(0x00C0_7A11);
let n = 60_000usize;
let q = 0.01f64;
let theta = 2.0f64;
let clayton = copula_clayton(theta, n, &mut rng);
let (lower, _) = tail_dependence_coefficient(&clayton, q).unwrap();
let expected_lower = 2.0f64.powf(-1.0 / theta);
assert!(
(lower - expected_lower).abs() < 0.12,
"Clayton lower tail {lower} against {expected_lower}"
);
let high = 1.0 - q;
let (_, upper) = tail_dependence_coefficient(&clayton, high).unwrap();
let diagonal = (2.0 * high.powf(-theta) - 1.0).powf(-1.0 / theta);
let exact_upper = (1.0 - 2.0 * high + diagonal) / (1.0 - high);
assert!(
(upper - exact_upper).abs() < 0.10,
"Clayton upper tail {upper} against the finite-q value {exact_upper}"
);
assert!(upper < 0.2, "Clayton showed substantial upper tail dependence: {upper}");
let gumbel = copula_gumbel(theta, n, &mut rng);
let (_, upper) = tail_dependence_coefficient(&gumbel, 1.0 - q).unwrap();
let expected_upper = 2.0 - 2.0f64.powf(1.0 / theta);
assert!(
(upper - expected_upper).abs() < 0.12,
"Gumbel upper tail {upper} against {expected_upper}"
);
let mut previous = f64::INFINITY;
for &level in &[0.10f64, 0.05, 0.02, 0.01] {
let (low_end, _) = tail_dependence_coefficient(&gumbel, level).unwrap();
let exact = level.powf(2.0f64.powf(1.0 / theta) - 1.0);
assert!(
(low_end - exact).abs() < 0.05,
"Gumbel lower tail at q = {level} is {low_end}, not {exact}"
);
assert!(low_end < previous, "the lower coefficient rose as q fell to {level}");
previous = low_end;
}
assert!(previous < 0.35 * upper, "the lower tail did not decay against the upper one");
let rho = 0.7f64;
let corr = Matrix::from_rows(&[&[1.0, rho], &[rho, 1.0]]).unwrap();
let gaussian = copula_gaussian_sample(&corr, n, &mut rng).unwrap();
let df = 3.0f64;
let t_sample = copula_t_sample(&corr, df, n, &mut rng).unwrap();
let gx: Vec<f64> = gaussian.iter().map(|r| r[0]).collect();
let gy: Vec<f64> = gaussian.iter().map(|r| r[1]).collect();
let tx: Vec<f64> = t_sample.iter().map(|r| r[0]).collect();
let ty: Vec<f64> = t_sample.iter().map(|r| r[1]).collect();
let (g_tau, t_tau) = (kendall_tau(&gx, &gy), kendall_tau(&tx, &ty));
assert!(g_tau > 0.4, "the Gaussian sample is not strongly dependent");
assert!(
(g_tau - t_tau).abs() < 0.05,
"the two samples differ in rank dependence ({g_tau} against {t_tau}), so the tail \
comparison would not be like for like"
);
let limit = 2.0
* StudentT::new(df + 1.0)
.cdf(-(((df + 1.0) * (1.0 - rho)) / (1.0 + rho)).sqrt());
assert!(limit > 0.3, "the t copula's theoretical tail dependence is only {limit}");
let mut previous = f64::INFINITY;
let mut gaps = Vec::new();
for &level in &[0.10f64, 0.05, 0.02, 0.01] {
let (g_low, _) = tail_dependence_coefficient(&gaussian, level).unwrap();
let (t_low, _) = tail_dependence_coefficient(&t_sample, level).unwrap();
assert!(g_low < previous, "the Gaussian coefficient rose at q = {level}");
assert!(t_low > g_low, "at q = {level} the t copula did not exceed the Gaussian");
assert!(
(t_low - limit).abs() < 0.12,
"at q = {level} the t copula gave {t_low} against its limit {limit}"
);
gaps.push(t_low - g_low);
previous = g_low;
}
assert!(
gaps.windows(2).all(|w| w[1] > w[0]),
"the gap did not widen as the quantile tightened: {gaps:?}"
);
assert!(gaps[0] < 0.10, "the two were already separated in the body: {}", gaps[0]);
assert!(gaps[3] > 0.15, "the two never separated in the tail: {}", gaps[3]);
let (g_start, _) = tail_dependence_coefficient(&gaussian, 0.10).unwrap();
assert!(previous < 0.75 * g_start, "the Gaussian tail did not decay: {g_start} to {previous}");
let (_, g_high) = tail_dependence_coefficient(&gaussian, 1.0 - q).unwrap();
let (_, t_high) = tail_dependence_coefficient(&t_sample, 1.0 - q).unwrap();
assert!(t_high > g_high + 0.10, "the t copula's upper tail ({t_high}) matched the Gaussian's");
assert!((t_high - limit).abs() < 0.12, "the t upper tail {t_high} against limit {limit}");
}
#[test]
fn the_empirical_copula_transform_uniformises_any_margins() {
let mut rng = Rng::new(0xC0E1_1000);
let raw: Vec<Vec<f64>> = (0..2000)
.map(|_| {
let a = rng.next_gaussian();
vec![a.exp() * 1000.0, (0.8 * a + 0.6 * rng.next_gaussian()).tanh()]
})
.collect();
let pseudo = empirical_copula(&raw).unwrap();
assert_eq!(pseudo.len(), raw.len());
for j in 0..2 {
let column: Vec<f64> = pseudo.iter().map(|r| r[j]).collect();
assert!(column.iter().all(|&v| v > 0.0 && v < 1.0), "column {j} touched an endpoint");
let mut sorted = column.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
for (i, v) in sorted.iter().enumerate() {
assert!(
(v - (i + 1) as f64 / 2001.0).abs() < 1e-12,
"column {j} entry {i} is {v}"
);
}
}
let rx: Vec<f64> = raw.iter().map(|r| r[0]).collect();
let ry: Vec<f64> = raw.iter().map(|r| r[1]).collect();
let px: Vec<f64> = pseudo.iter().map(|r| r[0]).collect();
let py: Vec<f64> = pseudo.iter().map(|r| r[1]).collect();
assert!((kendall_tau(&rx, &ry) - kendall_tau(&px, &py)).abs() < 1e-12);
assert!(empirical_copula(&[]).is_err());
assert!(empirical_copula(&[vec![1.0, 2.0], vec![3.0]]).is_err());
}
#[test]
fn the_pickands_function_stays_between_its_two_bounds() {
let mut rng = Rng::new(0x91C1_0003);
let independent: Vec<Vec<f64>> =
(0..4000).map(|_| vec![rng.next_f64(), rng.next_f64()]).collect();
let comonotone: Vec<Vec<f64>> = (0..4000)
.map(|_| {
let u = rng.next_f64();
vec![u, u]
})
.collect();
let gumbel = copula_gumbel(2.0, 4000, &mut rng);
for &t in &[0.1f64, 0.25, 0.5, 0.75, 0.9] {
let bound = t.max(1.0 - t);
for (name, data) in
[("independent", &independent), ("comonotone", &comonotone), ("gumbel", &gumbel)]
{
let a = pickands_dependence(data, t).unwrap();
assert!(
(bound - 1e-9..=1.0 + 1e-9).contains(&a),
"{name} at t = {t} gave A = {a}, outside [{bound}, 1]"
);
}
let ind = pickands_dependence(&independent, t).unwrap();
let com = pickands_dependence(&comonotone, t).unwrap();
let gum = pickands_dependence(&gumbel, t).unwrap();
assert!(ind > 0.9, "independence gave A({t}) = {ind}");
assert!(com < bound + 0.05, "perfect dependence gave A({t}) = {com}");
assert!(gum < ind + 1e-9 && gum > com - 1e-9, "Gumbel A({t}) = {gum} is not between");
}
let strong = copula_gumbel(5.0, 4000, &mut rng);
assert!(
pickands_dependence(&strong, 0.5).unwrap()
< pickands_dependence(&gumbel, 0.5).unwrap(),
"stronger dependence did not lower A(1/2)"
);
assert!(pickands_dependence(&gumbel, 0.0).is_err());
assert!(pickands_dependence(&gumbel, 1.0).is_err());
assert!(pickands_dependence(&[vec![0.5, 0.5]], 0.5).is_err());
}
#[test]
fn the_copula_samplers_reject_malformed_input() {
let mut rng = Rng::new(9);
let not_square = Matrix::zeros(2, 3);
assert!(copula_gaussian_sample(¬_square, 10, &mut rng).is_err());
let not_correlation = Matrix::from_rows(&[&[2.0, 0.0], &[0.0, 2.0]]).unwrap();
assert!(copula_gaussian_sample(¬_correlation, 10, &mut rng).is_err());
let not_positive_definite =
Matrix::from_rows(&[&[1.0, 1.5], &[1.5, 1.0]]).unwrap();
assert!(copula_gaussian_sample(¬_positive_definite, 10, &mut rng).is_err());
let fine = Matrix::from_rows(&[&[1.0, 0.4], &[0.4, 1.0]]).unwrap();
assert!(copula_t_sample(&fine, 0.5, 10, &mut rng).is_err());
assert!(copula_fit_tau(&[vec![0.1, 0.2]], CopulaFamily::Clayton).is_err());
let negative: Vec<Vec<f64>> =
(0..500).map(|i| vec![i as f64, -(i as f64)]).collect();
assert!(copula_fit_tau(&negative, CopulaFamily::Clayton).is_err());
assert!(copula_fit_tau(&negative, CopulaFamily::Gumbel).is_err());
assert!(copula_fit_tau(&negative, CopulaFamily::Frank).is_err());
let mut rng = Rng::new(0xF2A4_0001);
let moderate = copula_frank(-4.0, 3000, &mut rng);
let fitted = copula_fit_tau(&moderate, CopulaFamily::Frank).unwrap();
assert!(fitted < 0.0, "a negatively dependent sample fitted {fitted}");
assert!(tail_dependence_coefficient(&negative, 0.0).is_err());
assert!(tail_dependence_coefficient(&negative, 1.0).is_err());
}
}