use crate::error::SolveError;
use crate::linalg::matrix::Matrix;
use crate::monte_carlo::Rng;
fn distance_squared(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
}
fn distance(a: &[f64], b: &[f64]) -> f64 {
distance_squared(a, b).sqrt()
}
fn check_data(data: &[Vec<f64>]) -> Result<usize, SolveError> {
if data.is_empty() {
return Err(SolveError::InvalidArgument("the dataset is empty"));
}
let dim = data[0].len();
if dim == 0 {
return Err(SolveError::InvalidArgument("the points have no coordinates"));
}
if data.iter().any(|p| p.len() != dim) {
return Err(SolveError::InvalidArgument("the dataset is ragged"));
}
if data.iter().flatten().any(|v| !v.is_finite()) {
return Err(SolveError::InvalidArgument("the data must be finite"));
}
Ok(dim)
}
#[derive(Debug, Clone, PartialEq)]
pub struct KMeans {
pub centroids: Vec<Vec<f64>>,
pub labels: Vec<usize>,
pub inertia_history: Vec<f64>,
pub iterations: usize,
}
impl KMeans {
pub fn inertia(&self) -> f64 {
*self.inertia_history.last().expect("there is always one iteration")
}
}
pub fn kmeans_pp_init(
data: &[Vec<f64>],
k: usize,
rng: &mut Rng,
) -> Result<Vec<Vec<f64>>, SolveError> {
check_data(data)?;
if k == 0 {
return Err(SolveError::InvalidArgument("need at least one cluster"));
}
if k > data.len() {
return Err(SolveError::InvalidArgument("more clusters than points"));
}
let n = data.len();
let first = (rng.next_u64() % n as u64) as usize;
let mut centres = vec![data[first].clone()];
let mut best = vec![0.0; n];
for (i, p) in data.iter().enumerate() {
best[i] = distance_squared(p, ¢res[0]);
}
while centres.len() < k {
let total: f64 = best.iter().sum();
let pick = if total > 0.0 {
let target = rng.next_f64() * total;
let mut running = 0.0;
let mut chosen = n - 1;
for (i, w) in best.iter().enumerate() {
running += w;
if running >= target {
chosen = i;
break;
}
}
chosen
} else {
(rng.next_u64() % n as u64) as usize
};
centres.push(data[pick].clone());
let latest = centres.last().expect("just pushed");
for (i, p) in data.iter().enumerate() {
best[i] = best[i].min(distance_squared(p, latest));
}
}
Ok(centres)
}
const RESTARTS: usize = 10;
pub fn kmeans(
data: &[Vec<f64>],
k: usize,
iters: usize,
rng: &mut Rng,
) -> Result<KMeans, SolveError> {
let mut best: Option<KMeans> = None;
for _ in 0..RESTARTS {
let run = kmeans_once(data, k, iters, rng)?;
if best.as_ref().is_none_or(|b| run.inertia() < b.inertia()) {
best = Some(run);
}
}
Ok(best.expect("at least one restart"))
}
pub fn kmeans_once(
data: &[Vec<f64>],
k: usize,
iters: usize,
rng: &mut Rng,
) -> Result<KMeans, SolveError> {
let dim = check_data(data)?;
if iters == 0 {
return Err(SolveError::InvalidArgument("need at least one iteration"));
}
let mut centroids = kmeans_pp_init(data, k, rng)?;
let n = data.len();
let mut labels = vec![0usize; n];
let mut history = Vec::with_capacity(iters);
let mut used = 0;
for step in 0..iters {
used = step + 1;
let mut changed = false;
for (i, p) in data.iter().enumerate() {
let mut best = 0;
let mut best_d = f64::INFINITY;
for (c, centre) in centroids.iter().enumerate() {
let d = distance_squared(p, centre);
if d < best_d {
best_d = d;
best = c;
}
}
if labels[i] != best {
changed = true;
}
labels[i] = best;
}
let mut sums = vec![vec![0.0; dim]; k];
let mut counts = vec![0usize; k];
for (i, p) in data.iter().enumerate() {
counts[labels[i]] += 1;
for j in 0..dim {
sums[labels[i]][j] += p[j];
}
}
for c in 0..k {
if counts[c] > 0 {
for j in 0..dim {
centroids[c][j] = sums[c][j] / counts[c] as f64;
}
}
}
for c in 0..k {
if counts[c] == 0 {
let (worst, _) = data
.iter()
.enumerate()
.map(|(i, p)| (i, distance_squared(p, ¢roids[labels[i]])))
.fold((0usize, -1.0), |acc, x| if x.1 > acc.1 { x } else { acc });
centroids[c] = data[worst].clone();
counts[labels[worst]] -= 1;
counts[c] = 1;
labels[worst] = c;
changed = true;
}
}
let inertia: f64 = data
.iter()
.enumerate()
.map(|(i, p)| distance_squared(p, ¢roids[labels[i]]))
.sum();
history.push(inertia);
if !changed && step > 0 {
break;
}
}
Ok(KMeans { centroids, labels, inertia_history: history, iterations: used })
}
pub fn elbow_data(
data: &[Vec<f64>],
k_range: &[usize],
iters: usize,
rng: &mut Rng,
) -> Result<Vec<(usize, f64)>, SolveError> {
if k_range.is_empty() {
return Err(SolveError::InvalidArgument("no cluster counts to try"));
}
let mut out = Vec::with_capacity(k_range.len());
for &k in k_range {
out.push((k, kmeans(data, k, iters, rng)?.inertia()));
}
Ok(out)
}
pub fn dbscan(data: &[Vec<f64>], eps: f64, min_pts: usize) -> Result<Vec<i32>, SolveError> {
check_data(data)?;
if !eps.is_finite() || eps <= 0.0 {
return Err(SolveError::InvalidArgument("eps must be positive"));
}
if min_pts == 0 {
return Err(SolveError::InvalidArgument("min_pts must be positive"));
}
let n = data.len();
let neighbours: Vec<Vec<usize>> = (0..n)
.map(|i| (0..n).filter(|&j| distance(&data[i], &data[j]) <= eps).collect())
.collect();
let core: Vec<bool> = neighbours.iter().map(|v| v.len() >= min_pts).collect();
let mut labels = vec![-1i32; n];
let mut next = 0i32;
for i in 0..n {
if !core[i] || labels[i] != -1 {
continue;
}
let cluster = next;
next += 1;
labels[i] = cluster;
let mut queue = vec![i];
while let Some(p) = queue.pop() {
if !core[p] {
continue;
}
for &q in &neighbours[p] {
if labels[q] == -1 {
labels[q] = cluster;
if core[q] {
queue.push(q);
}
}
}
}
}
Ok(labels)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Linkage {
Single,
Complete,
Average,
Centroid,
}
pub fn hierarchical_agglomerative(
data: &[Vec<f64>],
linkage: Linkage,
) -> Result<Vec<(usize, usize, f64)>, SolveError> {
check_data(data)?;
let n = data.len();
if n < 2 {
return Err(SolveError::InvalidArgument("need at least two points to merge"));
}
let squared = linkage == Linkage::Centroid;
let mut d = vec![vec![0.0f64; n]; n];
for i in 0..n {
for j in 0..n {
d[i][j] =
if squared { distance_squared(&data[i], &data[j]) } else { distance(&data[i], &data[j]) };
}
}
let mut active: Vec<usize> = (0..n).collect();
let mut size = vec![1usize; n];
let mut name: Vec<usize> = (0..n).collect();
let mut merges = Vec::with_capacity(n - 1);
for step in 0..n - 1 {
let (mut bi, mut bj, mut best) = (0usize, 1usize, f64::INFINITY);
for a in 0..active.len() {
for b in (a + 1)..active.len() {
let v = d[active[a]][active[b]];
if v < best {
best = v;
bi = a;
bj = b;
}
}
}
let (i, j) = (active[bi], active[bj]);
merges.push((name[i], name[j], if squared { best.max(0.0).sqrt() } else { best }));
let (ni, nj) = (size[i] as f64, size[j] as f64);
for &k in &active {
if k == i || k == j {
continue;
}
let (dki, dkj, dij) = (d[k][i], d[k][j], d[i][j]);
let updated = match linkage {
Linkage::Single => dki.min(dkj),
Linkage::Complete => dki.max(dkj),
Linkage::Average => (ni * dki + nj * dkj) / (ni + nj),
Linkage::Centroid => {
(ni * dki + nj * dkj) / (ni + nj) - ni * nj * dij / ((ni + nj) * (ni + nj))
}
};
d[k][i] = updated;
d[i][k] = updated;
}
size[i] += size[j];
name[i] = n + step;
active.remove(bj);
}
Ok(merges)
}
pub fn dendrogram_cut(
merges: &[(usize, usize, f64)],
n: usize,
k: usize,
) -> Result<Vec<usize>, SolveError> {
if n == 0 || k == 0 || k > n {
return Err(SolveError::InvalidArgument("the cluster count must lie in 1..=n"));
}
if merges.len() + 1 != n {
return Err(SolveError::DimensionMismatch { expected: n - 1, got: merges.len() + 1 });
}
let mut parent: Vec<usize> = (0..2 * n - 1).collect();
fn find(parent: &mut [usize], mut x: usize) -> usize {
while parent[x] != x {
parent[x] = parent[parent[x]];
x = parent[x];
}
x
}
for (t, &(a, b, _)) in merges.iter().take(n - k).enumerate() {
let ra = find(&mut parent, a);
let rb = find(&mut parent, b);
parent[ra] = n + t;
parent[rb] = n + t;
}
let mut seen = std::collections::HashMap::new();
let mut labels = Vec::with_capacity(n);
for i in 0..n {
let root = find(&mut parent, i);
let next = seen.len();
labels.push(*seen.entry(root).or_insert(next));
}
Ok(labels)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gmm {
pub weights: Vec<f64>,
pub means: Vec<Vec<f64>>,
pub covariances: Vec<Matrix>,
pub log_likelihood_history: Vec<f64>,
}
impl Gmm {
pub fn log_likelihood(&self) -> f64 {
*self.log_likelihood_history.last().expect("there is always one iteration")
}
}
const COVARIANCE_FLOOR: f64 = 1e-6;
pub fn gaussian_mixture_em(
data: &[Vec<f64>],
k: usize,
iters: usize,
rng: &mut Rng,
) -> Result<Gmm, SolveError> {
let dim = check_data(data)?;
if k == 0 {
return Err(SolveError::InvalidArgument("need at least one component"));
}
if k > data.len() {
return Err(SolveError::InvalidArgument("more components than points"));
}
if iters == 0 {
return Err(SolveError::InvalidArgument("need at least one iteration"));
}
let n = data.len();
let start = kmeans(data, k, 50, rng)?;
let mut means = start.centroids;
let scale: f64 = {
let mut total = 0.0;
for j in 0..dim {
let mean: f64 = data.iter().map(|p| p[j]).sum::<f64>() / n as f64;
total += data.iter().map(|p| (p[j] - mean) * (p[j] - mean)).sum::<f64>() / n as f64;
}
(total / dim as f64).max(1e-12)
};
let mut weights = vec![0.0; k];
let mut covariances = Vec::with_capacity(k);
for c in 0..k {
let members: Vec<&Vec<f64>> = data
.iter()
.zip(start.labels.iter())
.filter(|(_, &l)| l == c)
.map(|(p, _)| p)
.collect();
weights[c] = members.len() as f64 / n as f64;
let mut cov = Matrix::zeros(dim, dim);
if members.len() > 1 {
for p in &members {
for a in 0..dim {
for b in 0..dim {
let v = cov.get(a, b)
+ (p[a] - means[c][a]) * (p[b] - means[c][b]);
cov.set(a, b, v);
}
}
}
for a in 0..dim {
for b in 0..dim {
cov.set(a, b, cov.get(a, b) / members.len() as f64);
}
}
} else {
for a in 0..dim {
cov.set(a, a, scale);
}
}
for a in 0..dim {
cov.set(a, a, cov.get(a, a) + COVARIANCE_FLOOR * scale);
}
covariances.push(cov);
}
let mut history = Vec::with_capacity(iters);
let mut responsibility = vec![vec![0.0; k]; n];
for _ in 0..iters {
let mut factors = Vec::with_capacity(k);
for c in 0..k {
factors.push(crate::linalg::cholesky::cholesky(&covariances[c])?);
}
let mut total_log = 0.0;
for (i, p) in data.iter().enumerate() {
let mut logs = Vec::with_capacity(k);
for c in 0..k {
let l = &factors[c];
let diff: Vec<f64> = p.iter().zip(&means[c]).map(|(a, b)| a - b).collect();
let mut v = vec![0.0; dim];
for r in 0..dim {
let mut acc = diff[r];
for s in 0..r {
acc -= l.get(r, s) * v[s];
}
v[r] = acc / l.get(r, r);
}
let quad: f64 = v.iter().map(|x| x * x).sum();
let log_det: f64 = (0..dim).map(|r| l.get(r, r).ln()).sum::<f64>() * 2.0;
logs.push(
weights[c].max(1e-300).ln()
- 0.5 * quad
- 0.5 * log_det
- 0.5 * dim as f64 * std::f64::consts::TAU.ln(),
);
}
let peak = logs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let sum: f64 = logs.iter().map(|v| (v - peak).exp()).sum();
let log_p = peak + sum.ln();
total_log += log_p;
for c in 0..k {
responsibility[i][c] = (logs[c] - log_p).exp();
}
}
history.push(total_log);
for c in 0..k {
let mass: f64 = (0..n).map(|i| responsibility[i][c]).sum();
let safe = mass.max(1e-300);
weights[c] = mass / n as f64;
for j in 0..dim {
means[c][j] =
(0..n).map(|i| responsibility[i][c] * data[i][j]).sum::<f64>() / safe;
}
let mut cov = Matrix::zeros(dim, dim);
for i in 0..n {
let r = responsibility[i][c];
for a in 0..dim {
for b in 0..dim {
let v = cov.get(a, b)
+ r * (data[i][a] - means[c][a]) * (data[i][b] - means[c][b]);
cov.set(a, b, v);
}
}
}
for a in 0..dim {
for b in 0..dim {
cov.set(a, b, cov.get(a, b) / safe);
}
cov.set(a, a, cov.get(a, a) + COVARIANCE_FLOOR * scale);
}
covariances[c] = cov;
}
}
Ok(Gmm { weights, means, covariances, log_likelihood_history: history })
}
pub fn silhouette_score(data: &[Vec<f64>], labels: &[usize]) -> Result<f64, SolveError> {
check_data(data)?;
if labels.len() != data.len() {
return Err(SolveError::DimensionMismatch { expected: data.len(), got: labels.len() });
}
let clusters = labels.iter().copied().collect::<std::collections::BTreeSet<_>>();
if clusters.len() < 2 {
return Err(SolveError::InvalidArgument("silhouette needs at least two clusters"));
}
let n = data.len();
let mut total = 0.0;
for i in 0..n {
let own = labels[i];
let mut sums = std::collections::BTreeMap::new();
let mut counts = std::collections::BTreeMap::new();
for j in 0..n {
if i == j {
continue;
}
*sums.entry(labels[j]).or_insert(0.0) += distance(&data[i], &data[j]);
*counts.entry(labels[j]).or_insert(0usize) += 1;
}
let a = match counts.get(&own) {
Some(&c) if c > 0 => sums[&own] / c as f64,
_ => {
continue;
}
};
let b = clusters
.iter()
.filter(|&&c| c != own)
.filter_map(|c| counts.get(c).map(|&m| sums[c] / m as f64))
.fold(f64::INFINITY, f64::min);
if !b.is_finite() {
continue;
}
let denominator = a.max(b);
if denominator > 0.0 {
total += (b - a) / denominator;
}
}
Ok(total / n as f64)
}
pub fn adjusted_rand_index(a: &[usize], b: &[usize]) -> Result<f64, SolveError> {
if a.len() != b.len() {
return Err(SolveError::DimensionMismatch { expected: a.len(), got: b.len() });
}
if a.is_empty() {
return Err(SolveError::InvalidArgument("the partitions are empty"));
}
let n = a.len() as f64;
let choose2 = |x: f64| x * (x - 1.0) / 2.0;
let mut joint = std::collections::HashMap::new();
let mut left = std::collections::HashMap::new();
let mut right = std::collections::HashMap::new();
for (&x, &y) in a.iter().zip(b) {
*joint.entry((x, y)).or_insert(0.0) += 1.0;
*left.entry(x).or_insert(0.0) += 1.0;
*right.entry(y).or_insert(0.0) += 1.0;
}
let index: f64 = joint.values().map(|&v| choose2(v)).sum();
let sum_a: f64 = left.values().map(|&v| choose2(v)).sum();
let sum_b: f64 = right.values().map(|&v| choose2(v)).sum();
let expected = sum_a * sum_b / choose2(n);
let maximum = 0.5 * (sum_a + sum_b);
if (maximum - expected).abs() < 1e-300 {
return Ok(1.0);
}
Ok((index - expected) / (maximum - expected))
}
pub fn davies_bouldin(data: &[Vec<f64>], labels: &[usize]) -> Result<f64, SolveError> {
let dim = check_data(data)?;
if labels.len() != data.len() {
return Err(SolveError::DimensionMismatch { expected: data.len(), got: labels.len() });
}
let ids: Vec<usize> = labels.iter().copied().collect::<std::collections::BTreeSet<_>>().into_iter().collect();
if ids.len() < 2 {
return Err(SolveError::InvalidArgument("Davies-Bouldin needs at least two clusters"));
}
let mut centroids = Vec::with_capacity(ids.len());
let mut spreads = Vec::with_capacity(ids.len());
for &c in &ids {
let members: Vec<&Vec<f64>> =
data.iter().zip(labels).filter(|(_, &l)| l == c).map(|(p, _)| p).collect();
let mut centre = vec![0.0; dim];
for p in &members {
for j in 0..dim {
centre[j] += p[j];
}
}
for v in centre.iter_mut() {
*v /= members.len() as f64;
}
let spread =
members.iter().map(|p| distance(p, ¢re)).sum::<f64>() / members.len() as f64;
centroids.push(centre);
spreads.push(spread);
}
let mut total = 0.0;
for i in 0..ids.len() {
let mut worst = 0.0f64;
for j in 0..ids.len() {
if i == j {
continue;
}
let separation = distance(¢roids[i], ¢roids[j]);
if separation > 0.0 {
worst = worst.max((spreads[i] + spreads[j]) / separation);
} else {
worst = f64::INFINITY;
}
}
total += worst;
}
Ok(total / ids.len() as f64)
}
fn nearest(train: &[Vec<f64>], x: &[f64], k: usize) -> Vec<usize> {
let mut order: Vec<(usize, f64)> =
train.iter().enumerate().map(|(i, p)| (i, distance_squared(p, x))).collect();
order.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
order.into_iter().take(k).map(|(i, _)| i).collect()
}
pub fn knn_classify(
train: &[Vec<f64>],
labels: &[usize],
x: &[f64],
k: usize,
) -> Result<usize, SolveError> {
let dim = check_data(train)?;
if labels.len() != train.len() {
return Err(SolveError::DimensionMismatch { expected: train.len(), got: labels.len() });
}
if x.len() != dim {
return Err(SolveError::DimensionMismatch { expected: dim, got: x.len() });
}
if k == 0 || k > train.len() {
return Err(SolveError::InvalidArgument("k must lie in 1..=len"));
}
let mut votes = std::collections::BTreeMap::new();
for i in nearest(train, x, k) {
*votes.entry(labels[i]).or_insert(0usize) += 1;
}
Ok(votes
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0)))
.map(|(label, _)| label)
.expect("k is at least one"))
}
pub fn knn_regress(
train: &[Vec<f64>],
targets: &[f64],
x: &[f64],
k: usize,
) -> Result<f64, SolveError> {
let dim = check_data(train)?;
if targets.len() != train.len() {
return Err(SolveError::DimensionMismatch { expected: train.len(), got: targets.len() });
}
if x.len() != dim {
return Err(SolveError::DimensionMismatch { expected: dim, got: x.len() });
}
if k == 0 || k > train.len() {
return Err(SolveError::InvalidArgument("k must lie in 1..=len"));
}
let picked = nearest(train, x, k);
Ok(picked.iter().map(|&i| targets[i]).sum::<f64>() / k as f64)
}
#[cfg(test)]
mod tests {
use super::*;
fn blobs(rng: &mut Rng) -> (Vec<Vec<f64>>, Vec<usize>) {
let centres = [[0.0, 0.0], [8.0, 0.0], [4.0, 7.0]];
let mut data = Vec::new();
let mut truth = Vec::new();
for (label, c) in centres.iter().enumerate() {
for _ in 0..25 {
data.push(vec![c[0] + 0.4 * rng.next_gaussian(), c[1] + 0.4 * rng.next_gaussian()]);
truth.push(label);
}
}
(data, truth)
}
#[test]
fn lloyds_algorithm_never_goes_uphill() {
let mut rng = Rng::new(0x2a71_0c93);
let (data, _) = blobs(&mut rng);
for k in [1usize, 2, 3, 5, 8] {
let run = kmeans(&data, k, 100, &mut rng).unwrap();
for w in run.inertia_history.windows(2) {
assert!(w[1] <= w[0] + 1e-9, "the inertia rose from {} to {}", w[0], w[1]);
}
assert!(run.iterations <= 100);
assert_eq!(run.centroids.len(), k);
assert_eq!(run.labels.len(), data.len());
assert!(run.labels.iter().all(|&l| l < k));
for c in 0..k {
assert!(run.labels.contains(&c), "cluster {c} of {k} was empty");
}
}
let mut previous = f64::INFINITY;
for (_, inertia) in elbow_data(&data, &[1, 2, 3, 4, 6], 100, &mut rng).unwrap() {
assert!(inertia <= previous + 1e-6, "inertia rose with k");
previous = inertia;
}
}
#[test]
fn k_means_recovers_well_separated_blobs() {
let mut rng = Rng::new(0x51f0_2b74);
let (data, truth) = blobs(&mut rng);
let run = kmeans(&data, 3, 100, &mut rng).unwrap();
let agreement = adjusted_rand_index(&run.labels, &truth).unwrap();
assert!(agreement > 0.95, "the clustering agreed only {agreement} with the truth");
assert!(silhouette_score(&data, &run.labels).unwrap() > 0.7);
assert!(davies_bouldin(&data, &run.labels).unwrap() < 0.5);
}
#[test]
fn the_adjusted_index_is_one_for_agreement_and_blind_to_names() {
let a = vec![0usize, 0, 1, 1, 2, 2, 2];
assert!((adjusted_rand_index(&a, &a).unwrap() - 1.0).abs() < 1e-12);
let renamed: Vec<usize> = a.iter().map(|&x| (x + 2) % 3).collect();
assert!((adjusted_rand_index(&a, &renamed).unwrap() - 1.0).abs() < 1e-12);
let swapped: Vec<usize> = a.iter().map(|&x| if x == 0 { 1 } else if x == 1 { 0 } else { x }).collect();
assert!((adjusted_rand_index(&a, &swapped).unwrap() - 1.0).abs() < 1e-12);
let split = vec![0usize, 3, 1, 1, 2, 2, 2];
let partial = adjusted_rand_index(&a, &split).unwrap();
assert!(partial < 1.0 && partial > 0.3, "a near miss scored {partial}");
let one = vec![0usize; 7];
let singles: Vec<usize> = (0..7).collect();
assert_eq!(adjusted_rand_index(&one, &one).unwrap(), 1.0);
assert_eq!(adjusted_rand_index(&singles, &singles).unwrap(), 1.0);
assert!(adjusted_rand_index(&a, &[0, 1]).is_err());
assert!(adjusted_rand_index(&[], &[]).is_err());
}
#[test]
fn dbscan_finds_density_and_calls_the_rest_noise() {
let mut data = Vec::new();
for i in 0..12 {
let t = i as f64 * 0.1;
data.push(vec![t, 0.0]);
data.push(vec![t + 10.0, 0.0]);
}
data.push(vec![100.0, 100.0]);
let labels = dbscan(&data, 0.25, 3).unwrap();
assert_eq!(labels[labels.len() - 1], -1, "the outlier was not called noise");
let found: std::collections::BTreeSet<i32> =
labels.iter().copied().filter(|&l| l >= 0).collect();
assert_eq!(found.len(), 2, "found {} clusters, wanted 2", found.len());
assert_ne!(labels[0], labels[1]);
assert!(dbscan(&data, 0.25, 40).unwrap().iter().all(|&l| l == -1));
assert!(dbscan(&data, 0.05, 3).unwrap().iter().all(|&l| l == -1));
assert!(dbscan(&data, -1.0, 3).is_err());
assert!(dbscan(&data, 0.5, 0).is_err());
}
#[test]
fn single_and_complete_linkage_never_invert_but_centroid_can() {
let triangle =
vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![0.5, 0.8]];
for linkage in [Linkage::Single, Linkage::Complete, Linkage::Average] {
let merges = hierarchical_agglomerative(&triangle, linkage).unwrap();
for w in merges.windows(2) {
assert!(w[1].2 >= w[0].2 - 1e-12, "{linkage:?} inverted");
}
}
let centroid = hierarchical_agglomerative(&triangle, Linkage::Centroid).unwrap();
assert!(
centroid[1].2 < centroid[0].2,
"centroid linkage did not invert on the arrangement built to make it: {centroid:?}"
);
let mut rng = Rng::new(0x11b0_3d67);
let (data, truth) = blobs(&mut rng);
for linkage in [Linkage::Single, Linkage::Complete, Linkage::Average, Linkage::Centroid] {
let merges = hierarchical_agglomerative(&data, linkage).unwrap();
assert_eq!(merges.len(), data.len() - 1);
assert!(merges.iter().all(|m| m.2 >= 0.0));
let cut = dendrogram_cut(&merges, data.len(), 3).unwrap();
assert_eq!(cut.len(), data.len());
assert_eq!(cut.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 3);
if linkage == Linkage::Complete || linkage == Linkage::Average {
let agreement = adjusted_rand_index(&cut, &truth).unwrap();
assert!(agreement > 0.9, "{linkage:?} agreed only {agreement}");
}
}
assert!(hierarchical_agglomerative(&[vec![1.0]], Linkage::Single).is_err());
}
#[test]
fn the_dendrogram_cut_gives_the_counts_it_is_asked_for() {
let mut rng = Rng::new(0x77c2_10ea);
let (data, _) = blobs(&mut rng);
let merges = hierarchical_agglomerative(&data, Linkage::Complete).unwrap();
for k in [1usize, 2, 5, 20, data.len()] {
let cut = dendrogram_cut(&merges, data.len(), k).unwrap();
let distinct = cut.iter().copied().collect::<std::collections::BTreeSet<_>>();
assert_eq!(distinct.len(), k, "cutting for {k} gave {}", distinct.len());
}
assert!(dendrogram_cut(&merges, data.len(), 0).is_err());
assert!(dendrogram_cut(&merges, data.len(), data.len() + 1).is_err());
assert!(dendrogram_cut(&merges, data.len() + 1, 2).is_err());
}
fn overlapping(rng: &mut Rng) -> (Vec<Vec<f64>>, Vec<usize>) {
let centres = [[0.0, 0.0], [3.0, 0.0], [1.5, 2.6]];
let mut data = Vec::new();
let mut truth = Vec::new();
for (label, c) in centres.iter().enumerate() {
for _ in 0..40 {
data.push(vec![c[0] + 1.1 * rng.next_gaussian(), c[1] + 1.1 * rng.next_gaussian()]);
truth.push(label);
}
}
(data, truth)
}
#[test]
fn a_restart_finds_what_a_single_run_can_miss() {
let mut rng = Rng::new(0x2b0c_7741);
let (data, _) = blobs(&mut rng);
let mut single = 0.0;
let mut restarted = 0.0;
for _ in 0..20 {
let one = kmeans_once(&data, 3, 50, &mut rng).unwrap();
for w in one.inertia_history.windows(2) {
assert!(w[1] <= w[0] + 1e-9, "a single run went uphill");
}
single += one.inertia();
restarted += kmeans(&data, 3, 50, &mut rng).unwrap().inertia();
}
assert!(restarted <= single, "restarting did not help: {restarted} vs {single}");
}
#[test]
fn expectation_maximisation_never_goes_downhill() {
let mut rng = Rng::new(0x4a03_9c15);
let (data, truth) = overlapping(&mut rng);
let fit = gaussian_mixture_em(&data, 3, 40, &mut rng).unwrap();
assert!(
fit.log_likelihood() > fit.log_likelihood_history[0] + 1.0,
"the likelihood never moved: {:?}",
fit.log_likelihood_history
);
for w in fit.log_likelihood_history.windows(2) {
assert!(w[1] >= w[0] - 1e-6, "the likelihood fell from {} to {}", w[0], w[1]);
}
assert!((fit.weights.iter().sum::<f64>() - 1.0).abs() < 1e-12);
assert!(fit.weights.iter().all(|&w| w >= 0.0));
assert_eq!(fit.means.len(), 3);
assert_eq!(fit.covariances.len(), 3);
for w in &fit.weights {
assert!((w - 1.0 / 3.0).abs() < 0.15, "a component took {w}");
}
let assigned: Vec<usize> = data
.iter()
.map(|p| {
(0..3)
.min_by(|&a, &b| {
distance(p, &fit.means[a]).total_cmp(&distance(p, &fit.means[b]))
})
.unwrap()
})
.collect();
let agreement = adjusted_rand_index(&assigned, &truth).unwrap();
assert!(agreement > 0.3, "the mixture recovered only {agreement} of the structure");
let (clean, clean_truth) = blobs(&mut rng);
let sharp = gaussian_mixture_em(&clean, 3, 40, &mut rng).unwrap();
let hard: Vec<usize> = clean
.iter()
.map(|p| {
(0..3)
.min_by(|&a, &b| {
distance(p, &sharp.means[a]).total_cmp(&distance(p, &sharp.means[b]))
})
.unwrap()
})
.collect();
assert!(adjusted_rand_index(&hard, &clean_truth).unwrap() > 0.95);
}
#[test]
fn the_silhouette_is_bounded_and_rewards_separation() {
let mut rng = Rng::new(0x6f18_2c40);
let (data, truth) = blobs(&mut rng);
let good = silhouette_score(&data, &truth).unwrap();
assert!((-1.0..=1.0).contains(&good), "out of range at {good}");
assert!(good > 0.7, "well-separated blobs scored only {good}");
let scrambled: Vec<usize> = (0..data.len()).map(|i| i % 3).collect();
let bad = silhouette_score(&data, &scrambled).unwrap();
assert!((-1.0..=1.0).contains(&bad));
assert!(bad < 0.1, "a scrambled clustering scored {bad}");
assert!(good > bad);
assert!(davies_bouldin(&data, &truth).unwrap() < davies_bouldin(&data, &scrambled).unwrap());
assert!(silhouette_score(&data, &vec![0; data.len()]).is_err());
assert!(silhouette_score(&data, &truth[..3]).is_err());
assert!(davies_bouldin(&data, &vec![0; data.len()]).is_err());
}
#[test]
fn one_nearest_neighbour_reproduces_its_training_set() {
let mut rng = Rng::new(0x0c94_71fe);
let (data, truth) = blobs(&mut rng);
for (i, p) in data.iter().enumerate() {
assert_eq!(knn_classify(&data, &truth, p, 1).unwrap(), truth[i], "point {i}");
}
let targets: Vec<f64> = data.iter().map(|p| p[0] * 2.0 - p[1]).collect();
for (i, p) in data.iter().enumerate() {
let got = knn_regress(&data, &targets, p, 1).unwrap();
assert!((got - targets[i]).abs() < 1e-12, "point {i}");
}
let flat = vec![5.0; data.len()];
for k in [1usize, 3, 10] {
assert!((knn_regress(&data, &flat, &[1.0, 1.0], k).unwrap() - 5.0).abs() < 1e-12);
}
assert!(knn_classify(&data, &truth, &[0.0, 0.0], 0).is_err());
assert!(knn_classify(&data, &truth, &[0.0, 0.0], data.len() + 1).is_err());
assert!(knn_classify(&data, &truth, &[0.0], 1).is_err());
assert!(knn_classify(&data, &truth[..2], &[0.0, 0.0], 1).is_err());
assert!(knn_regress(&data, &targets[..2], &[0.0, 0.0], 1).is_err());
assert!(knn_regress(&data, &targets, &[0.0], 1).is_err());
}
#[test]
fn the_clusterers_refuse_impossible_arguments() {
let mut rng = Rng::new(3);
let data = vec![vec![0.0, 0.0], vec![1.0, 1.0], vec![2.0, 0.0]];
assert!(kmeans(&[], 1, 10, &mut rng).is_err());
assert!(kmeans(&[vec![], vec![]], 1, 10, &mut rng).is_err());
assert!(kmeans(&[vec![1.0], vec![1.0, 2.0]], 1, 10, &mut rng).is_err());
assert!(kmeans(&[vec![f64::NAN]], 1, 10, &mut rng).is_err());
assert!(kmeans(&data, 0, 10, &mut rng).is_err());
assert!(kmeans(&data, 9, 10, &mut rng).is_err());
assert!(kmeans(&data, 2, 0, &mut rng).is_err());
assert!(kmeans_pp_init(&data, 0, &mut rng).is_err());
assert!(kmeans_pp_init(&data, 9, &mut rng).is_err());
assert!(elbow_data(&data, &[], 10, &mut rng).is_err());
assert!(gaussian_mixture_em(&data, 0, 5, &mut rng).is_err());
assert!(gaussian_mixture_em(&data, 9, 5, &mut rng).is_err());
assert!(gaussian_mixture_em(&data, 2, 0, &mut rng).is_err());
let identical = vec![vec![1.0, 1.0]; 5];
let centres = kmeans_pp_init(&identical, 3, &mut rng).unwrap();
assert_eq!(centres.len(), 3);
assert!(kmeans(&identical, 2, 10, &mut rng).is_ok());
}
}