use crate::error::GeomError;
use crate::math::Vec2;
use crate::monte_carlo::Rng;
use crate::spatial::primitives::Rect;
use crate::statistics::inference::TestResult;
use std::f64::consts::PI;
#[must_use]
pub fn poisson_process(rate: f64, t_end: f64, rng: &mut Rng) -> Vec<f64> {
assert!(rate >= 0.0, "the rate must be non-negative");
assert!(t_end > 0.0, "the horizon must be positive");
let mut out = Vec::new();
if rate == 0.0 {
return out;
}
let mut t = 0.0;
loop {
t += -rng.next_f64().max(1e-300).ln() / rate;
if t > t_end {
return out;
}
out.push(t);
}
}
pub fn poisson_inhomogeneous(
rate_fn: &dyn Fn(f64) -> f64,
rate_max: f64,
t_end: f64,
rng: &mut Rng,
) -> Vec<f64> {
assert!(rate_max > 0.0, "the bounding rate must be positive");
assert!(t_end > 0.0, "the horizon must be positive");
let mut out = Vec::new();
let mut t = 0.0;
loop {
t += -rng.next_f64().max(1e-300).ln() / rate_max;
if t > t_end {
return out;
}
let r = rate_fn(t);
assert!(
r <= rate_max + 1e-9,
"the rate {r} at {t} exceeds the stated maximum {rate_max}"
);
if rng.next_f64() < r / rate_max {
out.push(t);
}
}
}
#[must_use]
pub fn poisson_2d(rate: f64, region: &Rect, rng: &mut Rng) -> Vec<Vec2> {
assert!(rate >= 0.0, "the rate must be non-negative");
let w = region.max.x - region.min.x;
let h = region.max.y - region.min.y;
assert!(w > 0.0 && h > 0.0, "the region must have positive area");
let n = poisson_count(rate * w * h, rng);
(0..n)
.map(|_| {
Vec2::new(region.min.x + w * rng.next_f64(), region.min.y + h * rng.next_f64())
})
.collect()
}
#[must_use]
pub fn poisson_3d(
rate: f64,
min: (f64, f64, f64),
max: (f64, f64, f64),
rng: &mut Rng,
) -> Vec<(f64, f64, f64)> {
assert!(rate >= 0.0, "the rate must be non-negative");
let (dx, dy, dz) = (max.0 - min.0, max.1 - min.1, max.2 - min.2);
assert!(dx > 0.0 && dy > 0.0 && dz > 0.0, "the box must have positive volume");
let n = poisson_count(rate * dx * dy * dz, rng);
(0..n)
.map(|_| {
(
min.0 + dx * rng.next_f64(),
min.1 + dy * rng.next_f64(),
min.2 + dz * rng.next_f64(),
)
})
.collect()
}
fn poisson_count(mean: f64, rng: &mut Rng) -> usize {
if mean <= 0.0 {
return 0;
}
if mean > 30.0 {
return (mean + mean.sqrt() * rng.next_gaussian()).max(0.0).round() as usize;
}
let limit = (-mean).exp();
let mut product = 1.0;
let mut k = 0usize;
loop {
product *= rng.next_f64();
if product <= limit {
return k;
}
k += 1;
}
}
pub fn compound_poisson(
rate: f64,
jump_dist: &dyn Fn(&mut Rng) -> f64,
t_end: f64,
rng: &mut Rng,
) -> Vec<(f64, f64)> {
poisson_process(rate, t_end, rng)
.into_iter()
.map(|t| {
let j = jump_dist(rng);
(t, j)
})
.collect()
}
#[must_use]
pub fn hawkes_intensity(events: &[f64], mu: f64, alpha: f64, beta: f64, t: f64) -> f64 {
mu + events
.iter()
.filter(|&&s| s < t)
.map(|&s| alpha * (-beta * (t - s)).exp())
.sum::<f64>()
}
#[must_use]
pub fn hawkes_branching_ratio(alpha: f64, beta: f64) -> f64 {
if beta > 0.0 {
alpha / beta
} else {
f64::INFINITY
}
}
#[must_use]
pub fn hawkes_process(mu: f64, alpha: f64, beta: f64, t_end: f64, rng: &mut Rng) -> Vec<f64> {
assert!(mu > 0.0 && beta > 0.0, "the background rate and decay must be positive");
assert!(alpha >= 0.0, "the excitation must be non-negative");
assert!(
hawkes_branching_ratio(alpha, beta) < 1.0,
"a branching ratio at or above one explodes"
);
assert!(t_end > 0.0, "the horizon must be positive");
let mut events: Vec<f64> = Vec::new();
let mut t = 0.0f64;
let mut excitation = 0.0f64;
loop {
let bound = mu + excitation;
t += -rng.next_f64().max(1e-300).ln() / bound;
if t > t_end {
return events;
}
let decayed = excitation * (-beta * (t - events.last().copied().unwrap_or(0.0))).exp();
let intensity = mu + decayed;
if rng.next_f64() * bound <= intensity {
events.push(t);
excitation = decayed + alpha;
} else {
excitation = decayed;
}
}
}
#[must_use]
pub fn hawkes_log_likelihood(events: &[f64], t_end: f64, mu: f64, alpha: f64, beta: f64) -> f64 {
if mu <= 0.0 || beta <= 0.0 || alpha < 0.0 {
return f64::NEG_INFINITY;
}
let mut total = -mu * t_end;
for &t in events {
total -= alpha / beta * (1.0 - (-beta * (t_end - t)).exp());
}
let mut a = 0.0f64;
for (i, &t) in events.iter().enumerate() {
if i > 0 {
a = (-beta * (t - events[i - 1])).exp() * (1.0 + a);
}
let lambda = mu + alpha * a;
if lambda <= 0.0 {
return f64::NEG_INFINITY;
}
total += lambda.ln();
}
total
}
#[must_use]
pub fn hawkes_fit_mle(events: &[f64], t_end: f64) -> (f64, f64, f64) {
assert!(events.len() >= 2, "fitting needs at least two events");
assert!(t_end > 0.0, "the horizon must be positive");
let observed = events.len() as f64 / t_end;
let mean_gap = t_end / events.len() as f64;
let mut best = (0.5 * observed, 0.4 / mean_gap, 1.0 / mean_gap);
let mut best_ll = hawkes_log_likelihood(events, t_end, best.0, best.1, best.2);
let mut scale = 0.5f64;
for _ in 0..60 {
let mut improved = false;
for axis in 0..3 {
for direction in [1.0f64, -1.0] {
let mut candidate = best;
let factor = (1.0 + scale * direction).max(0.05);
match axis {
0 => candidate.0 *= factor,
1 => candidate.1 *= factor,
_ => candidate.2 *= factor,
}
if candidate.1 >= candidate.2 {
continue;
}
let ll = hawkes_log_likelihood(events, t_end, candidate.0, candidate.1, candidate.2);
if ll > best_ll {
best_ll = ll;
best = candidate;
improved = true;
}
}
}
if !improved {
scale *= 0.6;
if scale < 1e-4 {
break;
}
}
}
best
}
pub fn renewal_process(
interarrival: &dyn Fn(&mut Rng) -> f64,
t_end: f64,
rng: &mut Rng,
) -> Vec<f64> {
assert!(t_end > 0.0, "the horizon must be positive");
let mut out = Vec::new();
let mut t = 0.0;
loop {
let gap = interarrival(rng);
assert!(gap > 0.0 && gap.is_finite(), "an interarrival time must be positive");
t += gap;
if t > t_end {
return out;
}
out.push(t);
}
}
pub fn renewal_function_estimate(
interarrival: &dyn Fn(&mut Rng) -> f64,
t: f64,
n_paths: usize,
rng: &mut Rng,
) -> f64 {
assert!(t > 0.0 && n_paths > 0, "the horizon and the path count must be positive");
let total: usize = (0..n_paths).map(|_| renewal_process(interarrival, t, rng).len()).sum();
total as f64 / n_paths as f64
}
pub fn cox_process(
rate_dist: &dyn Fn(&mut Rng) -> f64,
t_end: f64,
rng: &mut Rng,
) -> Vec<f64> {
let rate = rate_dist(rng);
assert!(rate >= 0.0, "a drawn rate must be non-negative");
poisson_process(rate, t_end, rng)
}
#[must_use]
pub fn matern_cluster_process(
parent_rate: f64,
cluster_radius: f64,
daughter_mean: f64,
region: &Rect,
rng: &mut Rng,
) -> Vec<Vec2> {
assert!(parent_rate > 0.0 && daughter_mean > 0.0, "the rates must be positive");
assert!(cluster_radius > 0.0, "the radius must be positive");
let grown = Rect {
min: Vec2::new(region.min.x - cluster_radius, region.min.y - cluster_radius),
max: Vec2::new(region.max.x + cluster_radius, region.max.y + cluster_radius),
};
let parents = poisson_2d(parent_rate, &grown, rng);
let mut out = Vec::new();
for p in parents {
for _ in 0..poisson_count(daughter_mean, rng) {
let r = cluster_radius * rng.next_f64().sqrt();
let a = 2.0 * PI * rng.next_f64();
let q = Vec2::new(p.x + r * a.cos(), p.y + r * a.sin());
if q.x >= region.min.x && q.x <= region.max.x && q.y >= region.min.y && q.y <= region.max.y
{
out.push(q);
}
}
}
out
}
#[must_use]
pub fn thomas_process(
parent_rate: f64,
spread: f64,
daughter_mean: f64,
region: &Rect,
rng: &mut Rng,
) -> Vec<Vec2> {
assert!(parent_rate > 0.0 && daughter_mean > 0.0, "the rates must be positive");
assert!(spread > 0.0, "the spread must be positive");
let margin = 4.0 * spread;
let grown = Rect {
min: Vec2::new(region.min.x - margin, region.min.y - margin),
max: Vec2::new(region.max.x + margin, region.max.y + margin),
};
let parents = poisson_2d(parent_rate, &grown, rng);
let mut out = Vec::new();
for p in parents {
for _ in 0..poisson_count(daughter_mean, rng) {
let q = Vec2::new(
p.x + spread * rng.next_gaussian(),
p.y + spread * rng.next_gaussian(),
);
if q.x >= region.min.x && q.x <= region.max.x && q.y >= region.min.y && q.y <= region.max.y
{
out.push(q);
}
}
}
out
}
#[must_use]
pub fn ripley_k(points: &[Vec2], region: &Rect, r_values: &[f64]) -> Vec<f64> {
let w = region.max.x - region.min.x;
let h = region.max.y - region.min.y;
assert!(w > 0.0 && h > 0.0, "the region must have positive area");
assert!(r_values.iter().all(|&r| r > 0.0), "the radii must be positive");
let n = points.len();
let area = w * h;
if n < 2 {
return vec![0.0; r_values.len()];
}
let intensity = n as f64 / area;
r_values
.iter()
.map(|&r| {
let mut total = 0.0;
for (i, p) in points.iter().enumerate() {
let weight = ripley_weight(*p, r, region);
for (j, q) in points.iter().enumerate() {
if i != j && p.distance_to(q) <= r {
total += weight;
}
}
}
total / (n as f64 * intensity)
})
.collect()
}
fn ripley_weight(p: Vec2, r: f64, region: &Rect) -> f64 {
const SAMPLES: usize = 72;
let inside = (0..SAMPLES)
.filter(|&k| {
let a = 2.0 * PI * k as f64 / SAMPLES as f64;
let x = p.x + r * a.cos();
let y = p.y + r * a.sin();
x >= region.min.x && x <= region.max.x && y >= region.min.y && y <= region.max.y
})
.count();
if inside == 0 {
1.0
} else {
SAMPLES as f64 / inside as f64
}
}
#[must_use]
pub fn l_function(points: &[Vec2], region: &Rect, r_values: &[f64]) -> Vec<f64> {
ripley_k(points, region, r_values).into_iter().map(|k| (k / PI).sqrt()).collect()
}
#[must_use]
pub fn pair_correlation(points: &[Vec2], region: &Rect, r: f64, dr: f64) -> f64 {
let w = region.max.x - region.min.x;
let h = region.max.y - region.min.y;
assert!(w > 0.0 && h > 0.0, "the region must have positive area");
assert!(r > 0.0 && dr > 0.0 && dr < r, "the shell must be inside the radius");
let n = points.len();
if n < 2 {
return 0.0;
}
let area = w * h;
let intensity = n as f64 / area;
let mut total = 0.0;
for (i, p) in points.iter().enumerate() {
let weight = ripley_weight(*p, r, region);
for (j, q) in points.iter().enumerate() {
let d = p.distance_to(q);
if i != j && (d - r).abs() <= dr / 2.0 {
total += weight;
}
}
}
let shell = 2.0 * PI * r * dr;
total / (n as f64 * intensity * shell)
}
#[must_use]
pub fn nearest_neighbor_index(points: &[Vec2], region: &Rect) -> f64 {
let w = region.max.x - region.min.x;
let h = region.max.y - region.min.y;
assert!(w > 0.0 && h > 0.0, "the region must have positive area");
assert!(points.len() >= 2, "the index needs at least two points");
let intensity = points.len() as f64 / (w * h);
let mean_observed: f64 = points
.iter()
.enumerate()
.map(|(i, p)| {
points
.iter()
.enumerate()
.filter(|&(j, _)| j != i)
.map(|(_, q)| p.distance_to(q))
.fold(f64::INFINITY, f64::min)
})
.sum::<f64>()
/ points.len() as f64;
let expected = 1.0 / (2.0 * intensity.sqrt());
mean_observed / expected
}
pub fn quadrat_test(
points: &[Vec2],
region: &Rect,
nx: usize,
ny: usize,
) -> Result<TestResult, GeomError> {
if nx < 2 || ny < 2 {
return Err(GeomError::InvalidArgument("the grid must be at least two by two"));
}
let w = region.max.x - region.min.x;
let h = region.max.y - region.min.y;
if w <= 0.0 || h <= 0.0 {
return Err(GeomError::InvalidArgument("the region must have positive area"));
}
let cells = nx * ny;
if points.len() < cells {
return Err(GeomError::InvalidArgument("too few points for this many cells"));
}
let mut counts = vec![0.0f64; cells];
for p in points {
let cx = (((p.x - region.min.x) / w * nx as f64) as usize).min(nx - 1);
let cy = (((p.y - region.min.y) / h * ny as f64) as usize).min(ny - 1);
counts[cy * nx + cx] += 1.0;
}
let expected = vec![points.len() as f64 / cells as f64; cells];
Ok(crate::statistics::inference::chi_squared_gof(&counts, &expected))
}
pub fn ks_test_exponential_interarrivals(events: &[f64]) -> Result<TestResult, GeomError> {
if events.len() < 3 {
return Err(GeomError::InvalidArgument("at least three events are required"));
}
let gaps: Vec<f64> = std::iter::once(events[0])
.chain(events.windows(2).map(|w| w[1] - w[0]))
.collect();
if gaps.iter().any(|&g| g <= 0.0) {
return Err(GeomError::InvalidArgument("the gaps must be positive"));
}
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
let cdf = move |x: f64| if x <= 0.0 { 0.0 } else { 1.0 - (-x / mean).exp() };
Ok(crate::statistics::inference::ks_test_one_sample(&gaps, &cdf))
}
#[must_use]
pub fn branching_process_gw(
offspring_pmf: &[f64],
generations: usize,
rng: &mut Rng,
) -> Vec<u64> {
assert!(!offspring_pmf.is_empty(), "the offspring distribution must not be empty");
assert!(offspring_pmf.iter().all(|&p| p >= 0.0), "a probability is negative");
let total: f64 = offspring_pmf.iter().sum();
assert!((total - 1.0).abs() < 1e-9, "the offspring distribution must sum to one");
let mut sizes = Vec::with_capacity(generations + 1);
let mut n = 1u64;
sizes.push(n);
for _ in 0..generations {
let mut next = 0u64;
for _ in 0..n {
let u = rng.next_f64();
let mut acc = 0.0;
for (k, &p) in offspring_pmf.iter().enumerate() {
acc += p;
if u < acc {
next += k as u64;
break;
}
}
}
n = next;
sizes.push(n);
if n == 0 {
sizes.resize(generations + 1, 0);
break;
}
if n > 2_000 {
sizes.resize(generations + 1, n);
break;
}
}
sizes
}
#[must_use]
pub fn extinction_probability(offspring_pgf_coeffs: &[f64]) -> f64 {
assert!(!offspring_pgf_coeffs.is_empty(), "the distribution must not be empty");
assert!(offspring_pgf_coeffs.iter().all(|&p| p >= 0.0), "a probability is negative");
let total: f64 = offspring_pgf_coeffs.iter().sum();
assert!((total - 1.0).abs() < 1e-9, "the distribution must sum to one");
let pgf = |s: f64| -> f64 {
offspring_pgf_coeffs.iter().enumerate().map(|(k, &p)| p * s.powi(k as i32)).sum()
};
let mean: f64 = offspring_pgf_coeffs.iter().enumerate().map(|(k, &p)| k as f64 * p).sum();
if mean <= 1.0 {
return 1.0;
}
let mut q = 0.0f64;
for _ in 0..10_000 {
let next = pgf(q);
if (next - q).abs() < 1e-15 {
break;
}
q = next;
}
q.clamp(0.0, 1.0)
}
#[must_use]
pub fn yule_process(birth_rate: f64, t_end: f64, rng: &mut Rng) -> Vec<f64> {
assert!(birth_rate > 0.0 && t_end > 0.0, "the rate and horizon must be positive");
let mut out = Vec::new();
let mut t = 0.0;
let mut n = 1u64;
loop {
t += -rng.next_f64().max(1e-300).ln() / (birth_rate * n as f64);
if t > t_end || n > 100_000 {
return out;
}
n += 1;
out.push(t);
}
}
#[must_use]
pub fn birth_death_simulate(
birth: f64,
death: f64,
n0: u64,
t_end: f64,
rng: &mut Rng,
) -> Vec<(f64, u64)> {
assert!(birth >= 0.0 && death >= 0.0, "the rates must be non-negative");
assert!(t_end > 0.0, "the horizon must be positive");
let mut out = vec![(0.0, n0)];
let mut t = 0.0;
let mut n = n0;
loop {
if n == 0 || n > 1_000 {
return out;
}
let total = (birth + death) * n as f64;
if total <= 0.0 {
return out;
}
t += -rng.next_f64().max(1e-300).ln() / total;
if t > t_end {
return out;
}
if rng.next_f64() < birth / (birth + death) {
n += 1;
} else {
n -= 1;
}
out.push((t, n));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mean(x: &[f64]) -> f64 {
x.iter().sum::<f64>() / x.len() as f64
}
fn variance(x: &[f64]) -> f64 {
let m = mean(x);
x.iter().map(|v| (v - m) * (v - m)).sum::<f64>() / x.len() as f64
}
fn unit_square() -> Rect {
Rect { min: Vec2::new(0.0, 0.0), max: Vec2::new(1.0, 1.0) }
}
#[test]
fn the_poisson_process_has_its_three_defining_properties() {
let mut rng = Rng::new(0x_9015);
let rate = 4.0f64;
let t_end = 3.0f64;
let runs: Vec<Vec<f64>> = (0..25_000).map(|_| poisson_process(rate, t_end, &mut rng)).collect();
let counts: Vec<f64> = runs.iter().map(|r| r.len() as f64).collect();
let expected = rate * t_end;
assert!((mean(&counts) - expected).abs() < 0.06, "the mean count is {}", mean(&counts));
assert!(
(variance(&counts) / expected - 1.0).abs() < 0.04,
"the variance is {} against {expected}",
variance(&counts)
);
let mut observed = vec![0.0f64; 30];
for &c in &counts {
if (c as usize) < 30 {
observed[c as usize] += 1.0;
}
}
let mut factorial = 1.0f64;
for k in 0..30usize {
if k > 0 {
factorial *= k as f64;
}
let pk = (-expected).exp() * expected.powi(k as i32) / factorial;
let seen = observed[k] / counts.len() as f64;
assert!(
(seen - pk).abs() < 0.008,
"P(N = {k}) came out at {seen} against {pk}"
);
}
for r in runs.iter().take(200) {
assert!(r.windows(2).all(|w| w[0] < w[1]), "the events are out of order");
assert!(r.iter().all(|&t| t > 0.0 && t <= t_end), "an event left the horizon");
}
let long = poisson_process(rate, 2_000.0, &mut rng);
let ks = ks_test_exponential_interarrivals(&long).expect("enough events");
assert!(ks.p_value > 0.001, "the gaps failed the exponential test at p = {}", ks.p_value);
let all: Vec<f64> = runs.iter().flatten().copied().collect();
assert!((mean(&all) - t_end / 2.0).abs() < 0.01, "the points are not uniform");
assert!(poisson_process(0.0, 1.0, &mut rng).is_empty());
}
#[test]
fn thinning_reproduces_a_varying_rate() {
let mut rng = Rng::new(0x_7417);
let rate_fn = |t: f64| 2.0 + 2.0 * t;
let t_end = 4.0f64;
let runs: Vec<Vec<f64>> = (0..15_000)
.map(|_| poisson_inhomogeneous(&rate_fn, 10.0, t_end, &mut rng))
.collect();
let expected = 2.0 * t_end + t_end * t_end;
let counts: Vec<f64> = runs.iter().map(|r| r.len() as f64).collect();
assert!(
(mean(&counts) - expected).abs() < 0.08,
"the mean count is {} against {expected}",
mean(&counts)
);
assert!(
(variance(&counts) / expected - 1.0).abs() < 0.05,
"an inhomogeneous Poisson count should still have variance equal to its mean"
);
let first: f64 =
runs.iter().map(|r| r.iter().filter(|&&t| t < 2.0).count() as f64).sum::<f64>()
/ runs.len() as f64;
let second = mean(&counts) - first;
let want_first = 2.0 * 2.0 + 4.0;
assert!((first - want_first).abs() < 0.06, "the first half holds {first}");
assert!((second - (expected - want_first)).abs() < 0.06, "the second half holds {second}");
let bad = |_t: f64| 100.0f64;
assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut r = Rng::new(1);
poisson_inhomogeneous(&bad, 1.0, 1.0, &mut r)
}))
.is_err());
}
#[test]
fn a_spatial_poisson_pattern_looks_completely_random() {
let mut rng = Rng::new(0x_5A47);
let region = unit_square();
let radii = [0.05f64, 0.08, 0.12, 0.16];
let mut totals = vec![0.0f64; radii.len()];
let reps = 20;
for _ in 0..reps {
let pts = poisson_2d(400.0, ®ion, &mut rng);
for (i, k) in ripley_k(&pts, ®ion, &radii).into_iter().enumerate() {
totals[i] += k;
}
}
for (i, &r) in radii.iter().enumerate() {
let observed = totals[i] / reps as f64;
let want = PI * r * r;
assert!(
(observed / want - 1.0).abs() < 0.1,
"K({r}) came out at {observed} against {want}"
);
}
let pts = poisson_2d(900.0, ®ion, &mut rng);
for (i, l) in l_function(&pts, ®ion, &radii).into_iter().enumerate() {
assert!(
(l - radii[i]).abs() < 0.02,
"L({}) came out at {l}",
radii[i]
);
}
for r in [0.06f64, 0.1, 0.15] {
let g = pair_correlation(&pts, ®ion, r, 0.02);
assert!((g - 1.0).abs() < 0.15, "the pair correlation at {r} is {g}");
}
let index = nearest_neighbor_index(&pts, ®ion);
assert!((index - 1.0).abs() < 0.06, "the nearest neighbour index is {index}");
let q = quadrat_test(&pts, ®ion, 6, 6).expect("enough points");
assert!(q.p_value > 0.001, "a random pattern was rejected at p = {}", q.p_value);
let counts: Vec<f64> =
(0..8_000).map(|_| poisson_2d(20.0, ®ion, &mut rng).len() as f64).collect();
assert!((mean(&counts) - 20.0).abs() < 0.2);
assert!((variance(&counts) / 20.0 - 1.0).abs() < 0.08);
let boxed = poisson_3d(1_000.0, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0), &mut rng);
assert!((boxed.len() as f64 - 1_000.0).abs() < 150.0);
}
#[test]
fn clustered_patterns_are_detected_as_clustered() {
let mut rng = Rng::new(0x_C1005);
let region = unit_square();
let radii = [0.04f64, 0.08, 0.12];
for (name, pts) in [
("Matern", matern_cluster_process(40.0, 0.04, 25.0, ®ion, &mut rng)),
("Thomas", thomas_process(40.0, 0.02, 25.0, ®ion, &mut rng)),
] {
assert!(pts.len() > 300, "{name} produced only {} points", pts.len());
assert!(
pts.iter().all(|p| p.x >= 0.0 && p.x <= 1.0 && p.y >= 0.0 && p.y <= 1.0),
"{name} put a point outside the region"
);
for (i, k) in ripley_k(&pts, ®ion, &radii).into_iter().enumerate() {
let csr = PI * radii[i] * radii[i];
assert!(k > 1.5 * csr, "{name}: K({}) is {k} against {csr}", radii[i]);
}
let index = nearest_neighbor_index(&pts, ®ion);
assert!(index < 0.8, "{name}: the nearest neighbour index is {index}");
let q = quadrat_test(&pts, ®ion, 6, 6).expect("enough points");
assert!(q.p_value < 0.01, "{name}: the quadrat test did not reject, p = {}", q.p_value);
let close = pair_correlation(&pts, ®ion, 0.03, 0.02);
assert!(close > 1.5, "{name}: the short-range correlation is only {close}");
}
let pts = matern_cluster_process(80.0, 0.05, 20.0, ®ion, &mut rng);
let border = pts
.iter()
.filter(|p| p.x < 0.1 || p.x > 0.9 || p.y < 0.1 || p.y > 0.9)
.count() as f64;
let border_area = 1.0 - 0.8 * 0.8;
let density_border = border / border_area;
let density_all = pts.len() as f64;
assert!(
(density_border / density_all - 1.0).abs() < 0.25,
"the border density is {density_border} against {density_all}, so the margin is wrong"
);
}
#[test]
fn the_hawkes_process_excites_itself_and_can_be_fitted() {
let (mu, alpha, beta) = (0.8f64, 0.6f64, 1.4f64);
let ratio = hawkes_branching_ratio(alpha, beta);
assert!((ratio - alpha / beta).abs() < 1e-12);
assert!(ratio < 1.0);
let history = [0.5f64, 1.0, 2.5];
let want = mu
+ alpha * (-beta * (3.0 - 0.5f64)).exp()
+ alpha * (-beta * (3.0 - 1.0f64)).exp()
+ alpha * (-beta * (3.0 - 2.5f64)).exp();
assert!((hawkes_intensity(&history, mu, alpha, beta, 3.0) - want).abs() < 1e-12);
assert!((hawkes_intensity(&history, mu, alpha, beta, 0.25) - mu).abs() < 1e-12);
let mut rng = Rng::new(0x_4A00);
let t_end = 8_000.0f64;
let events = hawkes_process(mu, alpha, beta, t_end, &mut rng);
let observed = events.len() as f64 / t_end;
let want_rate = mu / (1.0 - ratio);
assert!(
(observed / want_rate - 1.0).abs() < 0.06,
"the rate came out at {observed} against {want_rate}"
);
let ks = ks_test_exponential_interarrivals(&events).expect("enough events");
assert!(ks.p_value < 1e-6, "the Hawkes gaps looked exponential, p = {}", ks.p_value);
let truth_ll = hawkes_log_likelihood(&events, t_end, mu, alpha, beta);
for (dm, da, db) in [(0.5f64, 1.0f64, 1.0f64), (1.0, 0.4, 1.0), (1.0, 1.0, 2.0)] {
let off = hawkes_log_likelihood(&events, t_end, mu * dm, alpha * da, beta * db);
assert!(off < truth_ll, "a displaced parameter scored higher: {off} against {truth_ll}");
}
let (fm, fa, fb) = hawkes_fit_mle(&events, t_end);
assert!((fm / mu - 1.0).abs() < 0.15, "mu was fitted at {fm} against {mu}");
assert!(
(fa / fb / ratio - 1.0).abs() < 0.15,
"the branching ratio was fitted at {} against {ratio}",
fa / fb
);
assert!(hawkes_log_likelihood(&events, t_end, fm, fa, fb) >= truth_ll - 1.0);
assert!(std::panic::catch_unwind(|| {
let mut r = Rng::new(1);
hawkes_process(1.0, 2.0, 1.0, 10.0, &mut r)
})
.is_err());
}
#[test]
fn renewal_and_cox_processes_depart_from_poisson_as_they_should() {
let mut rng = Rng::new(0x_2E4E);
let tight = |r: &mut Rng| 1.0 + 0.05 * r.next_gaussian();
let counts: Vec<f64> =
(0..4_000).map(|_| renewal_process(&tight, 50.0, &mut rng).len() as f64).collect();
assert!((mean(&counts) - 49.0).abs() < 1.5, "the mean count is {}", mean(&counts));
assert!(
variance(&counts) < 0.2 * mean(&counts),
"a near-deterministic renewal process should be under-dispersed, not {}",
variance(&counts)
);
for (name, gap, want) in [
("tight", &tight as &dyn Fn(&mut Rng) -> f64, 1.0f64),
("exponential", &|r: &mut Rng| -r.next_f64().max(1e-300).ln() / 2.0, 0.5),
("uniform", &|r: &mut Rng| 0.2 + 1.6 * r.next_f64(), 1.0),
] {
let m = renewal_function_estimate(gap, 200.0, 200, &mut rng);
assert!(
(m / (200.0 / want) - 1.0).abs() < 0.05,
"{name}: the renewal function is {m} against {}",
200.0 / want
);
}
let rate_dist = |r: &mut Rng| if r.next_f64() < 0.5 { 1.0 } else { 9.0 };
let cox: Vec<f64> =
(0..15_000).map(|_| cox_process(&rate_dist, 4.0, &mut rng).len() as f64).collect();
let m = mean(&cox);
assert!((m - 20.0).abs() < 0.5, "the Cox mean is {m}");
assert!(
variance(&cox) > 3.0 * m,
"a Cox process should be over-dispersed: variance {} against mean {m}",
variance(&cox)
);
}
#[test]
fn compound_poisson_sums_match_walds_identity() {
let mut rng = Rng::new(0x_C044);
let rate = 3.0f64;
let t_end = 5.0f64;
let jump = |r: &mut Rng| 2.0 + r.next_gaussian();
let totals: Vec<f64> = (0..25_000)
.map(|_| compound_poisson(rate, &jump, t_end, &mut rng).iter().map(|&(_, j)| j).sum())
.collect();
let lambda_t = rate * t_end;
let want_mean = lambda_t * 2.0;
let want_var = lambda_t * (2.0f64 * 2.0 + 1.0);
assert!(
(mean(&totals) - want_mean).abs() < 0.15,
"the mean is {} against {want_mean}",
mean(&totals)
);
assert!(
(variance(&totals) / want_var - 1.0).abs() < 0.05,
"the variance is {} against {want_var}",
variance(&totals)
);
let one = compound_poisson(rate, &jump, t_end, &mut rng);
assert!(one.windows(2).all(|w| w[0].0 < w[1].0), "the marked times are out of order");
}
#[test]
fn branching_processes_go_extinct_as_predicted() {
assert!((extinction_probability(&[0.6, 0.4]) - 1.0).abs() < 1e-12);
assert!((extinction_probability(&[0.5, 0.5]) - 1.0).abs() < 1e-12);
assert!((extinction_probability(&[0.25, 0.5, 0.25]) - 1.0).abs() < 1e-12, "critical");
let q = extinction_probability(&[0.25, 0.0, 0.75]);
assert!((q - 1.0 / 3.0).abs() < 1e-9, "the fixed point came out at {q}");
for pmf in [
vec![0.25f64, 0.0, 0.75],
vec![0.3, 0.2, 0.5],
vec![0.1, 0.1, 0.3, 0.5],
] {
let q = extinction_probability(&pmf);
let g: f64 = pmf.iter().enumerate().map(|(k, &p)| p * q.powi(k as i32)).sum();
assert!((g - q).abs() < 1e-9, "g({q}) is {g}, so it is not a fixed point");
if q > 1e-6 {
let below = q * 0.5;
let gb: f64 = pmf.iter().enumerate().map(|(k, &p)| p * below.powi(k as i32)).sum();
assert!(gb > below, "a smaller fixed point exists");
}
}
let mut rng = Rng::new(0x_6704);
let pmf = [0.25f64, 0.0, 0.75];
let want = extinction_probability(&pmf);
let trials = 12_000;
let extinct = (0..trials)
.filter(|_| *branching_process_gw(&pmf, 40, &mut rng).last().expect("non-empty") == 0)
.count() as f64
/ trials as f64;
assert!(
(extinct - want).abs() < 0.02,
"{extinct} of the lineages died out against a predicted {want}"
);
let sub = [0.7f64, 0.3];
let survived = (0..2_000)
.filter(|_| *branching_process_gw(&sub, 60, &mut rng).last().expect("non-empty") > 0)
.count();
assert_eq!(survived, 0, "a subcritical process survived {survived} times");
for _ in 0..200 {
let path = branching_process_gw(&pmf, 40, &mut rng);
if let Some(first_zero) = path.iter().position(|&v| v == 0) {
assert!(path[first_zero..].iter().all(|&v| v == 0), "a lineage came back");
}
}
assert!(std::panic::catch_unwind(|| extinction_probability(&[0.5, 0.6])).is_err());
}
#[test]
fn birth_and_death_processes_grow_and_die_as_predicted() {
let mut rng = Rng::new(0x_B124);
let rate = 0.5f64;
let t = 4.0f64;
let sizes: Vec<f64> = (0..15_000)
.map(|_| 1.0 + yule_process(rate, t, &mut rng).len() as f64)
.collect();
let want = (rate * t).exp();
assert!(
(mean(&sizes) / want - 1.0).abs() < 0.05,
"the Yule population is {} against {want}",
mean(&sizes)
);
let want_var = (2.0 * rate * t).exp() - want;
assert!(
(variance(&sizes) / want_var - 1.0).abs() < 0.1,
"the Yule variance is {} against {want_var}",
variance(&sizes)
);
let one = yule_process(rate, t, &mut rng);
assert!(one.windows(2).all(|w| w[0] < w[1]));
assert!(one.iter().all(|&s| s > 0.0 && s <= t));
let (birth, death, n0) = (1.0f64, 0.4f64, 2u64);
let want_extinct = (death / birth).powi(n0 as i32);
let trials = 3_000;
let extinct = (0..trials)
.filter(|_| {
let path = birth_death_simulate(birth, death, n0, 60.0, &mut rng);
path.last().expect("non-empty").1 == 0
})
.count() as f64
/ trials as f64;
assert!(
(extinct - want_extinct).abs() < 0.03,
"{extinct} died out against a predicted {want_extinct}"
);
let doomed = (0..500)
.filter(|_| {
birth_death_simulate(0.3, 1.0, 3, 300.0, &mut rng).last().expect("non-empty").1 == 0
})
.count();
assert_eq!(doomed, 500, "a subcritical birth-death process survived");
let path = birth_death_simulate(1.0, 0.9, 5, 50.0, &mut rng);
assert!(path.windows(2).all(|w| w[0].0 < w[1].0), "the event times are out of order");
assert!(
path.windows(2).all(|w| w[0].1.abs_diff(w[1].1) == 1),
"the population jumped by more than one"
);
}
}