Skip to main content

prospicio_prob/
copula.rs

1//! Copulas: dependence between marginals, separate from the marginals.
2//!
3//! A copula draws one vector of uniforms per simulation; marginals are
4//! applied by inverse transform ([`simulate`]). Simulation `i` uses only
5//! `StreamRng::new(seed, i)`, so results do not depend on thread count and
6//! any simulation replays alone (see `docs/design/risk.md`).
7
8use prospicio_core::{Error, Result, StreamRng};
9use prospicio_math::linalg::{cholesky, lower_mul, lower_solve};
10use prospicio_math::special::{ln_gamma, norm_cdf, norm_quantile, student_t_cdf};
11
12use crate::distribution::Distribution;
13use crate::gamma::standard_gamma;
14use crate::predictive::{ComponentKey, PredictiveDistribution};
15use crate::provenance::Provenance;
16
17/// A `d`-dimensional copula.
18pub trait Copula: Sync {
19    /// Number of dimensions.
20    fn dim(&self) -> usize;
21
22    /// Fills `u` (length [`dim`](Self::dim)) with one draw of uniforms in
23    /// `(0, 1)`.
24    fn sample(&self, rng: &mut StreamRng, u: &mut [f64]);
25}
26
27/// The Gaussian copula with correlation matrix `R`.
28///
29/// One draw takes `d` standard normals `z` by inverse transform, in order,
30/// sets `y = L z` with `L Lᵀ = R`, and returns `u_j = Φ(y_j)`. Kendall's
31/// tau between dimensions `i` and `j` is `(2 / π) asin(R_ij)`; there is no
32/// tail dependence.
33///
34/// ```
35/// use prospicio_core::StreamRng;
36/// use prospicio_prob::copula::{Copula, GaussianCopula};
37///
38/// let c = GaussianCopula::new(&[1.0, 0.5, 0.5, 1.0], 2).unwrap();
39/// let mut u = [0.0; 2];
40/// c.sample(&mut StreamRng::new(1, 0), &mut u);
41/// assert!(u.iter().all(|&x| x > 0.0 && x < 1.0));
42/// ```
43#[derive(Debug, Clone, PartialEq)]
44pub struct GaussianCopula {
45    dim: usize,
46    chol: Vec<f64>,
47}
48
49impl GaussianCopula {
50    /// A Gaussian copula from a `d × d` correlation matrix, row-major. It
51    /// must be symmetric with a unit diagonal and positive definite.
52    pub fn new(correlation: &[f64], dim: usize) -> Result<Self> {
53        Ok(Self {
54            dim,
55            chol: correlation_factor(correlation, dim)?,
56        })
57    }
58
59    /// The Cholesky factor `L` of the correlation matrix, row-major.
60    pub fn factor(&self) -> &[f64] {
61        &self.chol
62    }
63}
64
65impl Copula for GaussianCopula {
66    fn dim(&self) -> usize {
67        self.dim
68    }
69
70    fn sample(&self, rng: &mut StreamRng, u: &mut [f64]) {
71        correlated_normals(&self.chol, rng, u);
72        for x in u.iter_mut() {
73            *x = open01(norm_cdf(*x));
74        }
75    }
76}
77
78/// The Student t copula with correlation matrix `R` and `nu` degrees of
79/// freedom.
80///
81/// One draw takes `y = L z` as the Gaussian copula does, then a chi-square
82/// `w` with `nu` degrees of freedom (Marsaglia–Tsang gamma from the same
83/// stream), and returns `u_j = T_nu(y_j / sqrt(w / nu))`. Kendall's tau is
84/// `(2 / π) asin(R_ij)`, as for the Gaussian, but extremes occur together:
85/// the tail dependence is `2 T_{nu+1}(-sqrt((nu + 1)(1 - R_ij) / (1 + R_ij)))`.
86#[derive(Debug, Clone, PartialEq)]
87pub struct StudentTCopula {
88    dim: usize,
89    chol: Vec<f64>,
90    nu: f64,
91}
92
93impl StudentTCopula {
94    /// A t copula from a `d × d` correlation matrix (row-major, as for
95    /// [`GaussianCopula::new`]) and `nu > 0` degrees of freedom.
96    pub fn new(correlation: &[f64], dim: usize, nu: f64) -> Result<Self> {
97        if !nu.is_finite() || nu <= 0.0 {
98            return Err(invalid("nu", nu, "must be finite and positive"));
99        }
100        Ok(Self {
101            dim,
102            chol: correlation_factor(correlation, dim)?,
103            nu,
104        })
105    }
106
107    /// Degrees of freedom.
108    pub fn nu(&self) -> f64 {
109        self.nu
110    }
111}
112
113impl Copula for StudentTCopula {
114    fn dim(&self) -> usize {
115        self.dim
116    }
117
118    fn sample(&self, rng: &mut StreamRng, u: &mut [f64]) {
119        correlated_normals(&self.chol, rng, u);
120        let w = 2.0 * standard_gamma(rng, 0.5 * self.nu);
121        let scale = (w / self.nu).sqrt();
122        for x in u.iter_mut() {
123            *x = open01(student_t_cdf(*x / scale, self.nu));
124        }
125    }
126}
127
128/// An Archimedean copula family; see [`ArchimedeanCopula`].
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Archimedean {
131    Clayton,
132    Gumbel,
133    Frank,
134    Joe,
135}
136
137/// An exchangeable `d`-dimensional Archimedean copula with generator
138/// `ψ_θ`, sampled by Marshall and Olkin's frailty method: draw a frailty
139/// `V` (whose Laplace transform is `ψ`), then `d` unit exponentials `E_j`,
140/// and return `u_j = ψ(E_j / V)`.
141///
142/// | Family | `ψ(t)` | Frailty `V` | `θ` | Kendall's tau |
143/// |---|---|---|---|---|
144/// | Clayton | `(1 + t)^(-1/θ)` | Gamma(`1/θ`) | `> 0` | `θ / (θ + 2)` |
145/// | Gumbel | `exp(-t^(1/θ))` | positive stable(`1/θ`) | `>= 1` | `1 - 1/θ` |
146/// | Frank | `-ln(1 - (1 - e^-θ) e^-t) / θ` | logarithmic(`1 - e^-θ`) | `> 0` | `1 + 4 (D_1(θ) - 1) / θ` |
147/// | Joe | `1 - (1 - e^-t)^(1/θ)` | Sibuya(`1/θ`) | `>= 1` | `1 - 4 Σ_k 1 / (k (θk + 2)(θ(k - 1) + 2))` |
148///
149/// Clayton has lower-tail dependence `2^(-1/θ)`; Gumbel and Joe have
150/// upper-tail dependence `2 - 2^(1/θ)`; Frank has none. Draws use the
151/// frailty first, then the exponentials, all from the simulation's stream.
152///
153/// ```
154/// use prospicio_core::StreamRng;
155/// use prospicio_prob::copula::{Archimedean, ArchimedeanCopula, Copula};
156///
157/// // Clayton with tau = 0.5.
158/// let c = ArchimedeanCopula::new(Archimedean::Clayton, 2.0, 3).unwrap();
159/// let mut u = [0.0; 3];
160/// c.sample(&mut StreamRng::new(1, 0), &mut u);
161/// assert!(u.iter().all(|&x| x > 0.0 && x < 1.0));
162/// ```
163#[derive(Debug, Clone, PartialEq)]
164pub struct ArchimedeanCopula {
165    family: Archimedean,
166    theta: f64,
167    dim: usize,
168}
169
170impl ArchimedeanCopula {
171    /// A `dim`-dimensional copula of `family` with parameter `theta`.
172    pub fn new(family: Archimedean, theta: f64, dim: usize) -> Result<Self> {
173        let ok = theta.is_finite()
174            && match family {
175                Archimedean::Clayton | Archimedean::Frank => theta > 0.0,
176                Archimedean::Gumbel | Archimedean::Joe => theta >= 1.0,
177            };
178        if !ok {
179            let reason = match family {
180                Archimedean::Clayton | Archimedean::Frank => "must be finite and positive",
181                Archimedean::Gumbel | Archimedean::Joe => "must be finite and at least 1",
182            };
183            return Err(invalid("theta", theta, reason));
184        }
185        if dim == 0 {
186            return Err(invalid("dim", 0.0, "must be positive"));
187        }
188        Ok(Self { family, theta, dim })
189    }
190
191    pub fn family(&self) -> Archimedean {
192        self.family
193    }
194
195    pub fn theta(&self) -> f64 {
196        self.theta
197    }
198
199    /// The generator `ψ(t)` for `t >= 0`.
200    pub fn generator(&self, t: f64) -> f64 {
201        let th = self.theta;
202        match self.family {
203            Archimedean::Clayton => (-(t.ln_1p()) / th).exp(),
204            Archimedean::Gumbel => (-t.powf(1.0 / th)).exp(),
205            // -ln(1 + e^-t (e^-θ - 1)) / θ.
206            Archimedean::Frank => -((-t).exp() * (-th).exp_m1()).ln_1p() / th,
207            // 1 - (1 - e^-t)^(1/θ).
208            Archimedean::Joe => -((-(-t).exp_m1()).ln() / th).exp_m1(),
209        }
210    }
211
212    fn frailty(&self, rng: &mut StreamRng) -> f64 {
213        let th = self.theta;
214        match self.family {
215            Archimedean::Clayton => standard_gamma(rng, 1.0 / th),
216            Archimedean::Gumbel => positive_stable(rng, 1.0 / th),
217            Archimedean::Frank => logarithmic(rng, th),
218            Archimedean::Joe => sibuya(rng, 1.0 / th),
219        }
220    }
221}
222
223impl Copula for ArchimedeanCopula {
224    fn dim(&self) -> usize {
225        self.dim
226    }
227
228    fn sample(&self, rng: &mut StreamRng, u: &mut [f64]) {
229        let v = self.frailty(rng);
230        for x in u.iter_mut() {
231            let e = -rng.next_open01().ln();
232            *x = open01(self.generator(e / v));
233        }
234    }
235}
236
237/// Simulates marginals joined by a copula: in simulation `i`, draws `u`
238/// from `copula` with `StreamRng::new(seed, i)` and sets component `j` to
239/// `marginals[j].quantile(u_j)`.
240///
241/// The result has one component per marginal, keyed by `components`
242/// under `dims`, and records the seed in its provenance.
243///
244/// ```
245/// use prospicio_prob::copula::{GaussianCopula, simulate};
246/// use prospicio_prob::{Distribution, KeyValue, Lognormal, Provenance};
247///
248/// let motor = Lognormal::from_mean_cv(100.0, 0.2).unwrap();
249/// let property = Lognormal::from_mean_cv(50.0, 1.0).unwrap();
250/// let copula = GaussianCopula::new(&[1.0, 0.4, 0.4, 1.0], 2).unwrap();
251/// let pd = simulate(
252///     &copula,
253///     &[&motor, &property],
254///     vec!["lob".into()],
255///     vec![vec![KeyValue::from("motor")], vec![KeyValue::from("property")]],
256///     10_000,
257///     42,
258///     Provenance::new("portfolio"),
259/// )
260/// .unwrap();
261/// assert!((pd.mean() - 150.0).abs() < 3.0);
262/// ```
263pub fn simulate(
264    copula: &dyn Copula,
265    marginals: &[&(dyn Distribution + Sync)],
266    dims: Vec<String>,
267    components: Vec<ComponentKey>,
268    n_sims: usize,
269    seed: u64,
270    provenance: Provenance,
271) -> Result<PredictiveDistribution> {
272    if marginals.len() != copula.dim() {
273        return Err(invalid(
274            "marginals",
275            marginals.len() as f64,
276            "must have one marginal per copula dimension",
277        ));
278    }
279    let parallel = marginals.iter().all(|m| m.is_parallel_safe());
280    PredictiveDistribution::simulate_with(
281        parallel,
282        dims,
283        components,
284        n_sims,
285        seed,
286        provenance,
287        |rng, row| {
288            copula.sample(rng, row);
289            for (x, m) in row.iter_mut().zip(marginals) {
290                *x = m.quantile(*x).expect("copula uniforms lie in (0, 1)");
291            }
292        },
293    )
294}
295
296/// Iman–Conover target ranks: for each of `m` columns, the rank (0-based,
297/// ascending) that each of `n` rows should take so that the columns' rank
298/// correlation is close to `correlation`. Column `j > 0` shuffles its
299/// normal scores with stream `j` of `seed`.
300pub(crate) fn target_ranks(
301    n: usize,
302    m: usize,
303    correlation: &[f64],
304    seed: u64,
305) -> Result<Vec<Vec<usize>>> {
306    let target = correlation_factor(correlation, m)?;
307    if n < m + 1 {
308        return Err(invalid(
309            "n_sims",
310            n as f64,
311            "must exceed the number of components",
312        ));
313    }
314
315    // Shuffled normal scores, column-major.
316    let scores: Vec<f64> = (1..=n)
317        .map(|i| norm_quantile(i as f64 / (n + 1) as f64))
318        .collect();
319    let mut cols: Vec<Vec<f64>> = (0..m)
320        .map(|j| {
321            let mut c = scores.clone();
322            if j > 0 {
323                shuffle(&mut c, &mut StreamRng::new(seed, j as u64));
324            }
325            c
326        })
327        .collect();
328
329    // Rotate to exactly the target correlation: T = M F^-T P^T, row by row.
330    let actual = correlation_factor(&sample_correlation(&cols), m)
331        .map_err(|_| invalid("n_sims", n as f64, "too few to decorrelate the scores"))?;
332    let (mut row, mut y, mut t) = (vec![0.0; m], vec![0.0; m], vec![0.0; m]);
333    for i in 0..n {
334        for (r, col) in row.iter_mut().zip(&cols) {
335            *r = col[i];
336        }
337        lower_solve(&actual, &row, &mut y);
338        lower_mul(&target, &y, &mut t);
339        for (col, v) in cols.iter_mut().zip(&t) {
340            col[i] = *v;
341        }
342    }
343
344    Ok(cols
345        .iter()
346        .map(|col| {
347            let mut order: Vec<usize> = (0..n).collect();
348            order.sort_by(|&a, &b| col[a].total_cmp(&col[b]));
349            let mut rank = vec![0; n];
350            for (r, &i) in order.iter().enumerate() {
351                rank[i] = r;
352            }
353            rank
354        })
355        .collect())
356}
357
358/// Reorders each component's draws so the components have (close to) the
359/// target correlation of normal scores, by Iman and Conover (1982). Every component
360/// keeps exactly its own draws; only their pairing across simulations
361/// changes.
362///
363/// Builds an `n × m` matrix whose columns are the normal scores
364/// `Φ⁻¹(i / (n + 1))`, shuffled independently (column `j` by
365/// `StreamRng::new(seed, j)`; column 0 is not shuffled), transforms it to
366/// have exactly the correlation `correlation`, and gives each component's
367/// draws the ranks of the matching column. The correlation of the
368/// result's normal scores (van der Waerden) is then close to
369/// `correlation`, not exact, and Spearman's rho is close to
370/// `(6 / π) asin(correlation / 2)`, as for a Gaussian copula.
371///
372/// Use it to join results simulated separately, for example a reserve and
373/// a premium-risk distribution, without resimulating either.
374///
375/// ```
376/// use prospicio_prob::copula::iman_conover;
377/// use prospicio_prob::{Empirical, KeyValue, PredictiveDistribution, Provenance};
378///
379/// // Two components, both 1..=1000, simulated independently.
380/// let n = 1000;
381/// let draws: Vec<f64> = (0..n).flat_map(|i| [i as f64, ((i * 7919) % n) as f64]).collect();
382/// let pd = PredictiveDistribution::from_draws(
383///     vec!["lob".into()],
384///     vec![vec![KeyValue::Int(0)], vec![KeyValue::Int(1)]],
385///     draws,
386///     Provenance::new("example"),
387/// )
388/// .unwrap();
389/// let joined = iman_conover(&pd, &[1.0, 0.7, 0.7, 1.0], 3).unwrap();
390/// assert_eq!(joined.marginal(&vec![KeyValue::Int(1)]).unwrap().sorted(),
391///            pd.marginal(&vec![KeyValue::Int(1)]).unwrap().sorted());
392/// ```
393pub fn iman_conover(
394    pd: &PredictiveDistribution,
395    correlation: &[f64],
396    seed: u64,
397) -> Result<PredictiveDistribution> {
398    let m = pd.n_components();
399    let n = pd.n_sims();
400    let ranks = target_ranks(n, m, correlation, seed)?;
401
402    // Each component takes its sorted draws in the target ranks.
403    let mut draws = vec![0.0; n * m];
404    for (j, rank) in ranks.iter().enumerate() {
405        let mut sorted: Vec<f64> = (0..n).map(|i| pd.row(i).expect("in range")[j]).collect();
406        sorted.sort_by(f64::total_cmp);
407        for (i, &r) in rank.iter().enumerate() {
408            draws[i * m + j] = sorted[r];
409        }
410    }
411    let provenance = pd
412        .provenance()
413        .clone()
414        .param("iman_conover_correlation", format!("{correlation:?}"))
415        .param("iman_conover_seed", seed);
416    PredictiveDistribution::from_draws(
417        pd.dims().to_vec(),
418        pd.components().to_vec(),
419        draws,
420        provenance,
421    )
422}
423
424/// Pearson correlation matrix of columns, row-major.
425fn sample_correlation(cols: &[Vec<f64>]) -> Vec<f64> {
426    let m = cols.len();
427    let n = cols[0].len() as f64;
428    let centred: Vec<Vec<f64>> = cols
429        .iter()
430        .map(|c| {
431            let mean = c.iter().sum::<f64>() / n;
432            c.iter().map(|x| x - mean).collect()
433        })
434        .collect();
435    let norms: Vec<f64> = centred
436        .iter()
437        .map(|c| c.iter().map(|x| x * x).sum::<f64>().sqrt())
438        .collect();
439    let mut r = vec![0.0; m * m];
440    for i in 0..m {
441        r[i * m + i] = 1.0;
442        for j in 0..i {
443            let dot: f64 = centred[i].iter().zip(&centred[j]).map(|(a, b)| a * b).sum();
444            let v = dot / (norms[i] * norms[j]);
445            r[i * m + j] = v;
446            r[j * m + i] = v;
447        }
448    }
449    r
450}
451
452/// Keeps a uniform inside `(0, 1)`, where `norm_cdf` or a generator can
453/// round to 0 or 1 far in a tail; marginal quantiles are infinite there.
454fn open01(u: f64) -> f64 {
455    u.clamp(f64::MIN_POSITIVE, 1.0 - f64::EPSILON / 2.0)
456}
457
458/// A positive stable draw with Laplace transform `exp(-t^alpha)`,
459/// `0 < alpha <= 1`, by Kanter's representation (Chambers, Mallows and
460/// Stuck): one uniform angle, then one unit exponential.
461fn positive_stable(rng: &mut StreamRng, alpha: f64) -> f64 {
462    if alpha == 1.0 {
463        return 1.0;
464    }
465    let theta = std::f64::consts::PI * rng.next_open01();
466    let w = -rng.next_open01().ln();
467    let a = (alpha * theta).sin() / theta.sin().powf(1.0 / alpha);
468    let b = (((1.0 - alpha) * theta).sin() / w).powf((1.0 - alpha) / alpha);
469    a * b
470}
471
472/// A logarithmic-series draw, `P(V = k) = p^k / (-k ln(1 - p))` with
473/// `p = 1 - e^-theta`, by Kemp's (1981) LK algorithm: two uniforms.
474fn logarithmic(rng: &mut StreamRng, theta: f64) -> f64 {
475    let p = -(-theta).exp_m1();
476    let v = rng.next_open01();
477    let u = rng.next_open01();
478    if v > p {
479        return 1.0;
480    }
481    // q = 1 - (1 - p)^u = 1 - e^(-theta u).
482    let q = -(-theta * u).exp_m1();
483    if v < q * q {
484        (1.0 + v.ln() / q.ln()).floor()
485    } else if v > q {
486        1.0
487    } else {
488        2.0
489    }
490}
491
492/// A Sibuya draw, `P(V > k) = Γ(k + 1 - alpha) / (Γ(k + 1) Γ(1 - alpha))`
493/// for `0 < alpha <= 1`, by inverting the distribution function with one
494/// uniform. The tail is heavy (no mean), so the search runs in `f64`.
495fn sibuya(rng: &mut StreamRng, alpha: f64) -> f64 {
496    let u = rng.next_open01();
497    if alpha == 1.0 || u <= alpha {
498        return 1.0;
499    }
500    let target = (1.0 - u).ln();
501    let ln_survival =
502        |k: f64| ln_gamma(k + 1.0 - alpha) - ln_gamma(k + 1.0) - ln_gamma(1.0 - alpha);
503    // Smallest k with P(V > k) <= 1 - u: double, then bisect.
504    let (mut lo, mut hi) = (1.0, 2.0);
505    while ln_survival(hi) > target {
506        lo = hi;
507        hi *= 2.0;
508        if hi > 1e300 {
509            return hi;
510        }
511    }
512    while hi - lo > 1.0 {
513        let mid = (lo + (hi - lo) / 2.0).floor();
514        if ln_survival(mid) > target {
515            lo = mid;
516        } else {
517            hi = mid;
518        }
519    }
520    hi
521}
522
523/// Fisher–Yates shuffle with uniform indices from `rng`.
524fn shuffle(x: &mut [f64], rng: &mut StreamRng) {
525    for i in (1..x.len()).rev() {
526        let j = ((rng.next_open01() * (i + 1) as f64) as usize).min(i);
527        x.swap(i, j);
528    }
529}
530
531/// Checks a correlation matrix and returns its Cholesky factor.
532fn correlation_factor(r: &[f64], dim: usize) -> Result<Vec<f64>> {
533    if dim == 0 || r.len() != dim * dim {
534        return Err(invalid(
535            "correlation",
536            r.len() as f64,
537            "must be a non-empty dim × dim matrix",
538        ));
539    }
540    for i in 0..dim {
541        if r[i * dim + i] != 1.0 {
542            return Err(invalid("correlation", r[i * dim + i], "diagonal must be 1"));
543        }
544        for j in 0..i {
545            let (a, b) = (r[i * dim + j], r[j * dim + i]);
546            if a != b {
547                return Err(invalid("correlation", a, "must be symmetric"));
548            }
549            if !(-1.0..=1.0).contains(&a) {
550                return Err(invalid("correlation", a, "entries must be in [-1, 1]"));
551            }
552        }
553    }
554    cholesky(r, dim).ok_or_else(|| invalid("correlation", 0.0, "must be positive definite"))
555}
556
557/// Fills `out` with `L z` for standard normals `z` drawn in order by
558/// inverse transform.
559fn correlated_normals(chol: &[f64], rng: &mut StreamRng, out: &mut [f64]) {
560    let z: Vec<f64> = (0..out.len())
561        .map(|_| norm_quantile(rng.next_open01()))
562        .collect();
563    lower_mul(chol, &z, out);
564}
565
566fn invalid(name: &'static str, value: f64, reason: &'static str) -> Error {
567    Error::InvalidParameter {
568        name,
569        value,
570        reason,
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use std::f64::consts::PI;
578
579    fn draws(c: &dyn Copula, n: usize, seed: u64) -> Vec<Vec<f64>> {
580        (0..n)
581            .map(|i| {
582                let mut u = vec![0.0; c.dim()];
583                c.sample(&mut StreamRng::new(seed, i as u64), &mut u);
584                u
585            })
586            .collect()
587    }
588
589    fn kendall_tau(x: &[f64], y: &[f64]) -> f64 {
590        let n = x.len();
591        let mut s = 0.0;
592        for i in 0..n {
593            for j in 0..i {
594                s += ((x[i] - x[j]) * (y[i] - y[j])).signum();
595            }
596        }
597        s / (n * (n - 1) / 2) as f64
598    }
599
600    /// Kolmogorov–Smirnov distance from the uniform distribution.
601    fn ks_uniform(mut u: Vec<f64>) -> f64 {
602        u.sort_by(f64::total_cmp);
603        let n = u.len() as f64;
604        u.iter()
605            .enumerate()
606            .map(|(i, &x)| (x - i as f64 / n).max((i + 1) as f64 / n - x))
607            .fold(0.0, f64::max)
608    }
609
610    const R: [f64; 9] = [1.0, 0.6, -0.3, 0.6, 1.0, 0.1, -0.3, 0.1, 1.0];
611
612    #[test]
613    fn kendall_tau_matches_the_arcsine_law() {
614        let gauss = GaussianCopula::new(&R, 3).unwrap();
615        let t = StudentTCopula::new(&R, 3, 4.0).unwrap();
616        let t_half = StudentTCopula::new(&R, 3, 0.7).unwrap();
617        let n = 4_000;
618        for c in [&gauss as &dyn Copula, &t, &t_half] {
619            let u = draws(c, n, 9);
620            for (i, j) in [(0, 1), (0, 2), (1, 2)] {
621                let x: Vec<f64> = u.iter().map(|r| r[i]).collect();
622                let y: Vec<f64> = u.iter().map(|r| r[j]).collect();
623                let want = 2.0 / PI * R[i * 3 + j].asin();
624                // Standard error of tau is below 0.012 at n = 4,000.
625                let got = kendall_tau(&x, &y);
626                assert!((got - want).abs() < 0.04, "({i}, {j}): {got} vs {want}");
627            }
628        }
629    }
630
631    #[test]
632    fn margins_are_uniform() {
633        let n = 50_000;
634        for c in [
635            &GaussianCopula::new(&R, 3).unwrap() as &dyn Copula,
636            &StudentTCopula::new(&R, 3, 3.0).unwrap(),
637            &StudentTCopula::new(&R, 3, 0.7).unwrap(),
638        ] {
639            let u = draws(c, n, 2);
640            for j in 0..3 {
641                let d = ks_uniform(u.iter().map(|r| r[j]).collect());
642                // 0.1% critical value: 1.95 / sqrt(n).
643                assert!(d < 1.95 / (n as f64).sqrt(), "dimension {j}: {d}");
644                assert!(u.iter().all(|r| r[j] > 0.0 && r[j] < 1.0));
645            }
646        }
647    }
648
649    #[test]
650    fn t_copula_has_joint_extremes() {
651        let r = [1.0, 0.5, 0.5, 1.0];
652        let n = 200_000;
653        let q = 0.995;
654        let joint = |c: &dyn Copula| {
655            draws(c, n, 4)
656                .iter()
657                .filter(|u| u[0] > q && u[1] > q)
658                .count() as f64
659                / (n as f64 * (1.0 - q))
660        };
661        let gauss = joint(&GaussianCopula::new(&r, 2).unwrap());
662        let t = joint(&StudentTCopula::new(&r, 2, 3.0).unwrap());
663        // Limits as q -> 1: 0 for the Gaussian, 0.3125 for t(3) at 0.5.
664        let lambda = 2.0 * student_t_cdf(-(4.0f64 * 0.5 / 1.5).sqrt(), 4.0);
665        assert!((lambda - 0.3125).abs() < 1e-3, "{lambda}");
666        assert!(t > 1.5 * gauss, "t {t} vs Gaussian {gauss}");
667        assert!((t - lambda).abs() < 0.1, "{t} vs {lambda}");
668    }
669
670    #[test]
671    fn gamma_moments() {
672        for shape in [0.35, 1.0, 2.5, 40.0] {
673            let n = 100_000;
674            let mut rng = StreamRng::new(3, 0);
675            let x: Vec<f64> = (0..n).map(|_| standard_gamma(&mut rng, shape)).collect();
676            let mean = x.iter().sum::<f64>() / n as f64;
677            let var = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
678            let se = (shape / n as f64).sqrt();
679            assert!(
680                (mean - shape).abs() < 4.0 * se,
681                "shape {shape}: mean {mean}"
682            );
683            assert!((var / shape - 1.0).abs() < 0.05, "shape {shape}: var {var}");
684        }
685    }
686
687    #[test]
688    fn rejects_bad_correlations() {
689        assert!(GaussianCopula::new(&[1.0, 0.5, 0.4, 1.0], 2).is_err());
690        assert!(GaussianCopula::new(&[1.0, 1.5, 1.5, 1.0], 2).is_err());
691        assert!(GaussianCopula::new(&[2.0, 0.0, 0.0, 1.0], 2).is_err());
692        assert!(GaussianCopula::new(&[1.0, 0.0, 0.0], 2).is_err());
693        // Each pair is valid; the matrix is not positive definite.
694        let r = [1.0, 0.9, -0.9, 0.9, 1.0, 0.9, -0.9, 0.9, 1.0];
695        assert!(GaussianCopula::new(&r, 3).is_err());
696        assert!(StudentTCopula::new(&[1.0], 1, 0.0).is_err());
697    }
698
699    #[test]
700    fn simulate_is_reproducible_and_checks_dimensions() {
701        use crate::{KeyValue, Lognormal};
702        let a = Lognormal::from_mean_cv(10.0, 0.5).unwrap();
703        let b = Lognormal::from_mean_cv(20.0, 0.5).unwrap();
704        let c = StudentTCopula::new(&[1.0, 0.3, 0.3, 1.0], 2, 5.0).unwrap();
705        let keys = || vec![vec![KeyValue::Int(0)], vec![KeyValue::Int(1)]];
706        let run = || {
707            simulate(
708                &c,
709                &[&a, &b],
710                vec!["lob".into()],
711                keys(),
712                500,
713                8,
714                Provenance::new("t"),
715            )
716            .unwrap()
717        };
718        let (x, y) = (run(), run());
719        assert_eq!(x.draw_matrix(), y.draw_matrix());
720        // Row 17 replays alone.
721        let mut u = [0.0; 2];
722        c.sample(&mut StreamRng::new(8, 17), &mut u);
723        assert_eq!(
724            x.row(17).unwrap(),
725            [a.quantile(u[0]).unwrap(), b.quantile(u[1]).unwrap()]
726        );
727        assert!(
728            simulate(
729                &c,
730                &[&a],
731                vec!["lob".into()],
732                keys(),
733                10,
734                1,
735                Provenance::new("t")
736            )
737            .is_err()
738        );
739    }
740
741    /// Pearson correlation of `f(rank / (n + 1))` for 1-based ranks.
742    fn rank_correlation(x: &[f64], y: &[f64], f: fn(f64) -> f64) -> f64 {
743        let n = x.len();
744        let scores = |v: &[f64]| {
745            let mut order: Vec<usize> = (0..n).collect();
746            order.sort_by(|&a, &b| v[a].total_cmp(&v[b]));
747            let mut r = vec![0.0; n];
748            for (k, &i) in order.iter().enumerate() {
749                r[i] = f((k + 1) as f64 / (n + 1) as f64);
750            }
751            r
752        };
753        sample_correlation(&[scores(x), scores(y)])[1]
754    }
755
756    #[test]
757    fn iman_conover_keeps_marginals_and_reaches_the_target() {
758        use crate::{Empirical, KeyValue, Lognormal};
759        let a = Lognormal::from_mean_cv(100.0, 0.3).unwrap();
760        let b = Lognormal::from_mean_cv(50.0, 1.5).unwrap();
761        let c = Lognormal::from_mean_cv(10.0, 0.8).unwrap();
762        let independent =
763            GaussianCopula::new(&[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], 3).unwrap();
764        let keys: Vec<ComponentKey> = (0..3).map(|j| vec![KeyValue::Int(j)]).collect();
765        let n = 10_000;
766        let pd = simulate(
767            &independent,
768            &[&a, &b, &c],
769            vec!["lob".into()],
770            keys.clone(),
771            n,
772            1,
773            Provenance::new("t"),
774        )
775        .unwrap();
776        let joined = iman_conover(&pd, &R, 7).unwrap();
777        for key in &keys {
778            assert_eq!(
779                joined.marginal(key).unwrap().sorted(),
780                pd.marginal(key).unwrap().sorted()
781            );
782        }
783        let col = |j: usize| -> Vec<f64> { (0..n).map(|i| joined.row(i).unwrap()[j]).collect() };
784        for (i, j) in [(0, 1), (0, 2), (1, 2)] {
785            let r = R[i * 3 + j];
786            let normal_scores = rank_correlation(&col(i), &col(j), norm_quantile);
787            assert!(
788                (normal_scores - r).abs() < 0.01,
789                "({i}, {j}): {normal_scores} vs {r}"
790            );
791            let spearman = rank_correlation(&col(i), &col(j), |u| u);
792            let want = 6.0 / PI * (r / 2.0).asin();
793            assert!(
794                (spearman - want).abs() < 0.01,
795                "({i}, {j}): {spearman} vs {want}"
796            );
797        }
798        // Reproducible, and recorded.
799        assert_eq!(
800            iman_conover(&pd, &R, 7).unwrap().draw_matrix(),
801            joined.draw_matrix()
802        );
803        assert!(
804            joined
805                .provenance()
806                .parameters
807                .iter()
808                .any(|(k, _)| k == "iman_conover_seed")
809        );
810        // Bad inputs.
811        assert!(iman_conover(&pd, &[1.0, 0.5, 0.5, 1.0], 7).is_err());
812    }
813
814    fn frank_tau(theta: f64) -> f64 {
815        // Debye D_1(θ) = (1/θ) ∫_0^θ t / (e^t - 1) dt, by Simpson's rule.
816        let n = 20_000;
817        let h = theta / n as f64;
818        let f = |t: f64| if t == 0.0 { 1.0 } else { t / t.exp_m1() };
819        let mut sum = f(0.0) + f(theta);
820        for i in 1..n {
821            sum += f(i as f64 * h) * if i % 2 == 1 { 4.0 } else { 2.0 };
822        }
823        let d1 = sum * h / 3.0 / theta;
824        1.0 + 4.0 * (d1 - 1.0) / theta
825    }
826
827    fn joe_tau(theta: f64) -> f64 {
828        let s: f64 = (1..2_000_000)
829            .map(|k| {
830                let k = k as f64;
831                1.0 / (k * (theta * k + 2.0) * (theta * (k - 1.0) + 2.0))
832            })
833            .sum();
834        1.0 - 4.0 * s
835    }
836
837    #[test]
838    fn archimedean_kendall_tau() {
839        use Archimedean::*;
840        let n = 3_000;
841        for (family, theta, want) in [
842            (Clayton, 2.0, 0.5),
843            (Clayton, 0.3, 0.3 / 2.3),
844            (Gumbel, 1.0, 0.0),
845            (Gumbel, 2.5, 0.6),
846            (Frank, 5.0, frank_tau(5.0)),
847            (Frank, 0.5, frank_tau(0.5)),
848            (Joe, 2.0, joe_tau(2.0)),
849            (Joe, 6.0, joe_tau(6.0)),
850        ] {
851            let c = ArchimedeanCopula::new(family, theta, 3).unwrap();
852            let u = draws(&c, n, 21);
853            for (i, j) in [(0, 1), (1, 2)] {
854                let x: Vec<f64> = u.iter().map(|r| r[i]).collect();
855                let y: Vec<f64> = u.iter().map(|r| r[j]).collect();
856                let got = kendall_tau(&x, &y);
857                assert!(
858                    (got - want).abs() < 0.04,
859                    "{family:?}({theta}) ({i}, {j}): {got} vs {want}"
860                );
861            }
862        }
863        // Frank at θ = 5 has tau 0.4567 (Nelsen, Table 5.1 rounding).
864        assert!((frank_tau(5.0) - 0.4567).abs() < 1e-3);
865    }
866
867    #[test]
868    fn archimedean_margins_are_uniform() {
869        use Archimedean::*;
870        let n = 30_000;
871        for (family, theta) in [(Clayton, 1.5), (Gumbel, 3.0), (Frank, 8.0), (Joe, 4.0)] {
872            let c = ArchimedeanCopula::new(family, theta, 2).unwrap();
873            let u = draws(&c, n, 6);
874            for j in 0..2 {
875                let d = ks_uniform(u.iter().map(|r| r[j]).collect());
876                assert!(
877                    d < 1.95 / (n as f64).sqrt(),
878                    "{family:?} dimension {j}: {d}"
879                );
880            }
881        }
882    }
883
884    #[test]
885    fn archimedean_tails() {
886        use Archimedean::*;
887        let n = 200_000;
888        let q = 0.995;
889        let upper = |c: &dyn Copula| {
890            draws(c, n, 13)
891                .iter()
892                .filter(|u| u[0] > q && u[1] > q)
893                .count() as f64
894                / (n as f64 * (1.0 - q))
895        };
896        let lower = |c: &dyn Copula| {
897            draws(c, n, 13)
898                .iter()
899                .filter(|u| u[0] < 1.0 - q && u[1] < 1.0 - q)
900                .count() as f64
901                / (n as f64 * (1.0 - q))
902        };
903        let clayton = ArchimedeanCopula::new(Clayton, 2.0, 2).unwrap();
904        let gumbel = ArchimedeanCopula::new(Gumbel, 2.0, 2).unwrap();
905        // Clayton: lower tail 2^(-1/2) = 0.707, little in the upper tail.
906        assert!((lower(&clayton) - 0.5f64.sqrt()).abs() < 0.08);
907        assert!(upper(&clayton) < 0.2);
908        // Gumbel: upper tail 2 - 2^(1/2) = 0.586, little in the lower tail.
909        assert!((upper(&gumbel) - (2.0 - 2f64.sqrt())).abs() < 0.08);
910        assert!(lower(&gumbel) < 0.2);
911    }
912
913    #[test]
914    fn frailty_samplers() {
915        let mut rng = StreamRng::new(17, 0);
916        let n = 200_000;
917        // Logarithmic: mean p / (-(1 - p) ln(1 - p)) with p = 1 - e^-θ.
918        let theta: f64 = 3.0;
919        let p = 1.0 - (-theta).exp();
920        let mean = (0..n).map(|_| logarithmic(&mut rng, theta)).sum::<f64>() / n as f64;
921        let want = p / ((1.0 - p) * theta);
922        assert!((mean / want - 1.0).abs() < 0.02, "{mean} vs {want}");
923        // Sibuya: P(V = 1) = alpha, P(V = 2) = alpha (1 - alpha) / 2.
924        let alpha = 0.4;
925        let v: Vec<f64> = (0..n).map(|_| sibuya(&mut rng, alpha)).collect();
926        let share = |k: f64| v.iter().filter(|&&x| x == k).count() as f64 / n as f64;
927        assert!((share(1.0) - alpha).abs() < 0.005);
928        assert!((share(2.0) - alpha * (1.0 - alpha) / 2.0).abs() < 0.005);
929        assert!(v.iter().all(|&x| x >= 1.0 && x.fract() == 0.0));
930        // Positive stable: E[exp(-V)] = exp(-1) for any alpha.
931        for alpha in [0.3, 0.7] {
932            let m = (0..n)
933                .map(|_| (-positive_stable(&mut rng, alpha)).exp())
934                .sum::<f64>()
935                / n as f64;
936            assert!((m - (-1f64).exp()).abs() < 0.005, "alpha {alpha}: {m}");
937        }
938    }
939
940    #[test]
941    fn archimedean_rejects_bad_parameters() {
942        use Archimedean::*;
943        assert!(ArchimedeanCopula::new(Clayton, 0.0, 2).is_err());
944        assert!(ArchimedeanCopula::new(Gumbel, 0.9, 2).is_err());
945        assert!(ArchimedeanCopula::new(Frank, -1.0, 2).is_err());
946        assert!(ArchimedeanCopula::new(Joe, f64::INFINITY, 2).is_err());
947        assert!(ArchimedeanCopula::new(Clayton, 1.0, 0).is_err());
948        let g = ArchimedeanCopula::new(Gumbel, 2.0, 2).unwrap();
949        assert_eq!(g.generator(0.0), 1.0);
950        assert!(g.generator(1e6) < 1e-300);
951    }
952}