use std::collections::HashSet;
use rand::Rng;
use super::{Neighbor, SparseAffinities, SpectralParams, tSNE, tsne};
const D: usize = 4;
const THETA: f32 = 0.5;
const PERPLEXITY: f32 = 10.;
const EPOCHS: usize = 2_000;
const NO_DIMS: u8 = 2;
#[test]
fn set_learning_rate() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.learning_rate(15.);
assert_eq!(tsne.learning_rate, Some(15.));
}
#[test]
fn learning_rate_defaults_to_unset() {
let tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
assert_eq!(tsne.learning_rate, None);
}
#[test]
fn auto_learning_rate_hits_the_floor_for_small_n() {
let tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
assert_eq!(tsne.resolve_learning_rate(100), 50.0);
}
#[test]
fn auto_learning_rate_scales_with_n() {
let tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
assert_eq!(tsne.resolve_learning_rate(120_000), 120_000.0 / 12.0 / 4.0);
}
#[test]
fn explicit_learning_rate_overrides_the_auto_default() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.learning_rate(123.0);
assert_eq!(tsne.resolve_learning_rate(100), 123.0);
assert_eq!(tsne.resolve_learning_rate(1_000_000), 123.0);
}
#[test]
fn auto_learning_rate_is_coupled_to_early_exaggeration() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
let with_default = tsne.resolve_learning_rate(120_000);
tsne.early_exaggeration(6.0);
let with_half = tsne.resolve_learning_rate(120_000);
assert_eq!(with_half, 2.0 * with_default);
}
#[test]
fn auto_learning_rate_matches_historical_default_at_ten_thousand() {
let tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
let rate = tsne.resolve_learning_rate(10_000);
assert!(
(205.0..=212.0).contains(&rate),
"auto rate at n=10000 is {rate}, expected close to 208 (= 10000 / 12 / 4)"
);
}
#[test]
fn set_epochs() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.epochs(15);
assert_eq!(tsne.epochs, 15);
}
#[test]
fn set_momentum() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.momentum(15.);
assert_eq!(tsne.momentum, 15.);
}
#[test]
fn set_final_momentum() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.final_momentum(15.);
assert_eq!(tsne.final_momentum, 15.);
}
#[test]
fn set_momentum_switch_epoch() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.momentum_switch_epoch(15);
assert_eq!(tsne.momentum_switch_epoch, 15);
}
#[test]
fn set_stop_lying_epoch() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.stop_lying_epoch(15);
assert_eq!(tsne.stop_lying_epoch, 15);
}
#[test]
fn set_early_exaggeration() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.early_exaggeration(4.);
assert_eq!(tsne.early_exaggeration, 4.);
}
#[test]
fn early_exaggeration_default_is_twelve() {
let tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
assert_eq!(tsne.early_exaggeration, 12.);
}
#[test]
fn set_perplexity() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.perplexity(15.);
assert_eq!(tsne.perplexity, 15.);
}
#[test]
fn set_epoch_callback() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.epoch_callback(|_epoch, _embedding| {});
assert!(tsne.epoch_callback.is_some());
}
#[test]
fn set_initial_embedding() {
let mut tsne: tSNE<f32, f32> = tSNE::new(&[0.]);
tsne.initial_embedding([1., 2.]);
assert_eq!(tsne.initial_embedding, Some(vec![1., 2.]));
}
#[test]
fn kl_divergence_is_none_before_fitting() {
let data = [0.0_f32, 1.0, 2.0, 3.0];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
assert!(tsne.kl_divergence().is_none());
}
#[test]
fn kl_divergence_after_barnes_hut_is_finite_and_nonnegative() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(100)
.barnes_hut(THETA, |a, b| {
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y).powi(2))
.sum::<f32>()
.sqrt()
});
let kl = tsne.kl_divergence().expect("fitted");
assert!(kl.is_finite() && kl >= 0.0, "{kl}");
}
#[test]
fn parallel_barnes_hut_build_smoke() {
const N: usize = 160;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 5);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let neighbors = brute_force_neighbors(&samples, n_neighbors);
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(3)
.barnes_hut_with_neighbors(THETA, &neighbors);
let embedding = tsne.embedding();
assert_eq!(embedding.len(), N * NO_DIMS as usize);
assert!(embedding.iter().all(|v| v.is_finite()));
}
#[test]
fn kl_divergence_after_exact_is_finite_and_nonnegative() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(100)
.exact(|a, b| a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum());
let kl = tsne.kl_divergence().expect("fitted");
assert!(kl.is_finite() && kl >= 0.0, "{kl}");
}
#[cfg(feature = "csv")]
#[test]
#[ignore = "requires iris dataset"]
fn exact_tsne() {
let data: Vec<f32> =
crate::load_csv("iris.csv", true, Some(&[4]), |float| float.parse().unwrap()).unwrap();
let samples: Vec<&[f32]> = data.chunks(D).collect::<Vec<&[f32]>>();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(EPOCHS)
.exact(|sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum()
});
tsne.write_csv("iris_embedding_vanilla.csv").unwrap();
let embedding = tsne.embedding();
let points: Vec<_> = embedding.chunks(NO_DIMS as usize).collect();
assert_eq!(points.len(), samples.len());
assert!(tsne.kl_divergence().unwrap() < 0.5);
}
#[cfg(feature = "csv")]
#[test]
#[ignore = "requires iris dataset"]
fn barnes_hut_tsne() {
let data: Vec<f32> =
crate::load_csv("iris.csv", true, Some(&[4]), |float| float.parse().unwrap()).unwrap();
let samples: Vec<&[f32]> = data.chunks(D).collect::<Vec<&[f32]>>();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(EPOCHS)
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
})
.write_csv("iris_embedding_barnes_hut.csv")
.unwrap();
let embedding = tsne.embedding();
let points: Vec<_> = embedding.chunks(NO_DIMS as usize).collect();
assert_eq!(points.len(), samples.len());
assert!(tsne.kl_divergence().unwrap() < 5.0);
}
#[test]
fn epoch_callback_reports_each_barnes_hut_epoch() {
const N: usize = 60;
const DIM: usize = 4;
const RUN_EPOCHS: usize = 100;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut epochs_seen: Vec<usize> = Vec::new();
let mut last_snapshot: Vec<f32> = Vec::new();
let embedding = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(RUN_EPOCHS)
.epoch_callback(|epoch, snapshot| {
assert_eq!(snapshot.len(), N * NO_DIMS as usize);
epochs_seen.push(epoch);
last_snapshot.clear();
last_snapshot.extend_from_slice(snapshot);
})
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
assert!(tsne.epoch_callback.is_some());
tsne.embedding()
};
assert_eq!(epochs_seen, (0..RUN_EPOCHS).collect::<Vec<usize>>());
assert_eq!(last_snapshot, embedding);
}
#[test]
fn epoch_callback_reports_each_exact_epoch() {
const N: usize = 60;
const DIM: usize = 4;
const RUN_EPOCHS: usize = 50;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut epochs_seen: Vec<usize> = Vec::new();
let mut last_snapshot: Vec<f32> = Vec::new();
let embedding = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(RUN_EPOCHS)
.epoch_callback(|epoch, snapshot| {
assert_eq!(snapshot.len(), N * NO_DIMS as usize);
epochs_seen.push(epoch);
last_snapshot.clear();
last_snapshot.extend_from_slice(snapshot);
})
.exact(|sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum()
});
assert!(tsne.epoch_callback.is_some());
tsne.embedding()
};
assert_eq!(epochs_seen, (0..RUN_EPOCHS).collect::<Vec<usize>>());
assert_eq!(last_snapshot, embedding);
}
#[test]
fn epoch_callback_accepts_non_send_closure() {
use std::cell::RefCell;
use std::rc::Rc;
const N: usize = 40;
const DIM: usize = 4;
const RUN_EPOCHS: usize = 10;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let epochs_seen = Rc::new(RefCell::new(Vec::<usize>::new()));
let sink = Rc::clone(&epochs_seen);
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(RUN_EPOCHS)
.epoch_callback(move |epoch, _snapshot| {
sink.borrow_mut().push(epoch);
})
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
assert_eq!(
*epochs_seen.borrow(),
(0..RUN_EPOCHS).collect::<Vec<usize>>()
);
}
#[test]
fn warm_start_begins_from_initial_embedding_barnes_hut() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(300)
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
tsne.embedding()
};
let mut first_snapshot: Vec<f32> = Vec::new();
{
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(5)
.stop_lying_epoch(0)
.momentum_switch_epoch(0)
.initial_embedding(&seed[..])
.epoch_callback(|epoch, snapshot| {
if epoch == 0 {
first_snapshot.extend_from_slice(snapshot);
}
})
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
}
let dim = NO_DIMS as usize;
let displacement = mean_point_distance(&first_snapshot, &seed, dim);
let diagonal = bounding_box_diagonal(&seed, dim);
assert!(
displacement < 0.05 * diagonal,
"first epoch strayed {displacement} from the seed, its bounding box diagonal is {diagonal}"
);
let origin = vec![0.0_f32; seed.len()];
let random_displacement = mean_point_distance(&origin, &seed, dim);
assert!(
random_displacement > 10.0 * displacement,
"warm start indistinguishable from a random initialization: {displacement} against {random_displacement}"
);
}
#[test]
fn warm_start_begins_from_initial_embedding_exact() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(300)
.exact(|sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum()
});
tsne.embedding()
};
let mut first_snapshot: Vec<f32> = Vec::new();
{
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(5)
.stop_lying_epoch(0)
.momentum_switch_epoch(0)
.initial_embedding(&seed[..])
.epoch_callback(|epoch, snapshot| {
if epoch == 0 {
first_snapshot.extend_from_slice(snapshot);
}
})
.exact(|sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum()
});
}
let dim = NO_DIMS as usize;
let displacement = mean_point_distance(&first_snapshot, &seed, dim);
let diagonal = bounding_box_diagonal(&seed, dim);
assert!(
displacement < 0.05 * diagonal,
"first epoch strayed {displacement} from the seed, its bounding box diagonal is {diagonal}"
);
let origin = vec![0.0_f32; seed.len()];
let random_displacement = mean_point_distance(&origin, &seed, dim);
assert!(
random_displacement > 10.0 * displacement,
"warm start indistinguishable from a random initialization: {displacement} against {random_displacement}"
);
}
fn squared_euclidean(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum()
}
fn exact_snapshot_at<F>(capture: usize, configure: F) -> Vec<f32>
where
F: FnOnce(&mut tSNE<'_, f32, &[f32]>),
{
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = lcg_samples(N, NO_DIMS as usize, 99);
let mut snapshot: Vec<f32> = Vec::new();
{
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(capture + 1)
.initial_embedding(&seed[..]);
configure(&mut tsne);
tsne.epoch_callback(|epoch, current| {
if epoch == capture {
snapshot.extend_from_slice(current);
}
})
.exact(|a, b| squared_euclidean(a, b));
}
snapshot
}
#[test]
fn early_exaggeration_explicit_twelve_matches_default() {
let default_run = exact_snapshot_at(0, |_tsne| {});
let explicit_run = exact_snapshot_at(0, |tsne| {
tsne.early_exaggeration(12.0);
});
let dim = NO_DIMS as usize;
let drift = mean_point_distance(&default_run, &explicit_run, dim);
let diagonal = bounding_box_diagonal(&default_run, dim);
assert!(
drift <= 1e-4 * diagonal + 1e-6,
"explicit 12.0 strayed {drift} from the default, diagonal {diagonal}"
);
}
#[test]
fn early_exaggeration_changes_early_embedding() {
let strong = exact_snapshot_at(0, |tsne| {
tsne.early_exaggeration(12.0);
});
let weak = exact_snapshot_at(0, |tsne| {
tsne.early_exaggeration(4.0);
});
let dim = NO_DIMS as usize;
let difference = mean_point_distance(&strong, &weak, dim);
let diagonal = bounding_box_diagonal(&strong, dim);
assert!(
difference > 0.05 * diagonal,
"exaggeration 12.0 against 4.0 barely moved the first epoch: {difference} against diagonal {diagonal}"
);
}
#[test]
fn early_exaggeration_one_matches_stop_lying_zero() {
let no_exaggeration = exact_snapshot_at(0, |tsne| {
tsne.early_exaggeration(1.0);
});
let lying_disabled = exact_snapshot_at(0, |tsne| {
tsne.stop_lying_epoch(0);
});
let dim = NO_DIMS as usize;
let drift = mean_point_distance(&no_exaggeration, &lying_disabled, dim);
let diagonal = bounding_box_diagonal(&no_exaggeration, dim);
assert!(
drift <= 1e-4 * diagonal + 1e-6,
"the two no-exaggeration paths diverged: {drift} against diagonal {diagonal}"
);
}
#[test]
#[should_panic(expected = "initial embedding has")]
fn warm_start_rejects_wrong_length_barnes_hut() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.initial_embedding([0.0; 7])
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
}
#[test]
#[should_panic(expected = "initial embedding has")]
fn warm_start_rejects_wrong_length_exact() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.initial_embedding([0.0; 7])
.exact(|sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum()
});
}
#[test]
fn warm_start_seed_is_consumed_by_the_fit() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(300)
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
tsne.embedding()
};
let mut second_run_first_snapshot: Vec<f32> = Vec::new();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.initial_embedding(&seed[..]);
tsne.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
assert!(tsne.initial_embedding.is_none());
tsne.epochs(1)
.epoch_callback(|epoch, snapshot| {
if epoch == 0 {
second_run_first_snapshot.extend_from_slice(snapshot);
}
})
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
drop(tsne);
let dim = NO_DIMS as usize;
let from_seed = mean_point_distance(&second_run_first_snapshot, &seed, dim);
let from_origin = mean_point_distance(&second_run_first_snapshot, &vec![0.0; seed.len()], dim);
assert!(
from_origin < from_seed,
"second run continued from the consumed seed instead of random init: \
{from_origin} from origin against {from_seed} from the seed"
);
}
#[test]
fn stop_lying_epoch_zero_skips_exaggeration_barnes_hut() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(300)
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
tsne.embedding()
};
let first_step = |stop_lying_epoch: usize| -> f32 {
let mut first_snapshot: Vec<f32> = Vec::new();
{
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.stop_lying_epoch(stop_lying_epoch)
.initial_embedding(&seed[..])
.epoch_callback(|_epoch, snapshot| {
first_snapshot.extend_from_slice(snapshot);
})
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
}
mean_point_distance(&first_snapshot, &seed, NO_DIMS as usize)
};
let exaggerated = first_step(1000);
let truthful = first_step(0);
assert!(
truthful < exaggerated / 3.0,
"first epoch still exaggerated: moved {truthful} against {exaggerated} with 12x P values"
);
}
#[test]
fn stop_lying_epoch_zero_skips_exaggeration_exact() {
const N: usize = 60;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 7);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(300)
.exact(|sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum()
});
tsne.embedding()
};
let first_step = |stop_lying_epoch: usize| -> f32 {
let mut first_snapshot: Vec<f32> = Vec::new();
{
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.stop_lying_epoch(stop_lying_epoch)
.initial_embedding(&seed[..])
.epoch_callback(|_epoch, snapshot| {
first_snapshot.extend_from_slice(snapshot);
})
.exact(|sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum()
});
}
mean_point_distance(&first_snapshot, &seed, NO_DIMS as usize)
};
let exaggerated = first_step(1000);
let truthful = first_step(0);
assert!(
truthful < exaggerated / 3.0,
"first epoch still exaggerated: moved {truthful} against {exaggerated} with 12x P values"
);
}
fn euclidean(a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y).powi(2))
.sum::<f32>()
.sqrt()
}
fn brute_force_neighbors(samples: &[&[f32]], n_neighbors: usize) -> Vec<Vec<Neighbor<f32>>> {
(0..samples.len())
.map(|i| {
let mut distances: Vec<(usize, f32)> = (0..samples.len())
.filter(|&j| j != i)
.map(|j| (j, euclidean(samples[i], samples[j])))
.collect();
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
distances.truncate(n_neighbors);
distances
.into_iter()
.map(|(index, distance)| Neighbor { index, distance })
.collect()
})
.collect()
}
#[test]
fn barnes_hut_with_neighbors_matches_vptree_path() {
const N: usize = 80;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 11);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = lcg_samples(N, NO_DIMS as usize, 99);
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let neighbors = brute_force_neighbors(&samples, n_neighbors);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.unwrap();
let (reference, candidate) = pool.install(|| {
let reference = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(100)
.initial_embedding(&seed[..])
.barnes_hut(THETA, |a, b| euclidean(a, b));
tsne.embedding()
};
let candidate = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(100)
.initial_embedding(&seed[..])
.barnes_hut_with_neighbors(THETA, &neighbors);
tsne.embedding()
};
(reference, candidate)
});
assert_eq!(candidate, reference);
}
#[test]
#[should_panic(expected = "same length")]
fn barnes_hut_with_neighbors_rejects_ragged_rows() {
const N: usize = 80;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 11);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let mut neighbors = brute_force_neighbors(&samples, n_neighbors);
neighbors[0].pop();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.barnes_hut_with_neighbors(THETA, &neighbors);
}
#[test]
#[should_panic(expected = "out of range")]
fn barnes_hut_with_neighbors_rejects_out_of_range_index() {
const N: usize = 80;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 11);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let mut neighbors = brute_force_neighbors(&samples, n_neighbors);
neighbors[0][0].index = N;
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.barnes_hut_with_neighbors(THETA, &neighbors);
}
fn lcg_samples(n: usize, dim: usize, mut state: u64) -> Vec<f32> {
let mut data = Vec::with_capacity(n * dim);
for _ in 0..n * dim {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
data.push(((state >> 33) as f32 / u32::MAX as f32) - 0.5);
}
data
}
fn mean_point_distance(a: &[f32], b: &[f32], dim: usize) -> f32 {
assert_eq!(a.len(), b.len());
let n = a.len() / dim;
a.chunks_exact(dim)
.zip(b.chunks_exact(dim))
.map(|(p, q)| {
p.iter()
.zip(q.iter())
.map(|(x, y)| (x - y).powi(2))
.sum::<f32>()
.sqrt()
})
.sum::<f32>()
/ n as f32
}
fn bounding_box_diagonal(points: &[f32], dim: usize) -> f32 {
(0..dim)
.map(|d| {
let component = points.iter().skip(d).step_by(dim);
let min = component.clone().fold(f32::MAX, |a, &b| a.min(b));
let max = component.fold(f32::MIN, |a, &b| a.max(b));
(max - min).powi(2)
})
.sum::<f32>()
.sqrt()
}
#[test]
fn search_beta_converges_when_optimal_beta_below_one() {
let distances_row: Vec<f64> = (0..90)
.map(|i| (20.0 + 100.0 * (i as f64 + 1.0) / 90.0_f64).sqrt())
.collect();
let mut p_values_row: Vec<f64> = vec![0.0; 90];
let perplexity = 30.0;
tsne::search_beta(&mut p_values_row, &distances_row, &perplexity);
let entropy: f64 = p_values_row
.iter()
.copied()
.filter(|&p| p > 0.0)
.map(|p| -p * p.ln())
.sum();
let effective_perplexity = entropy.exp();
assert!(
(effective_perplexity - perplexity).abs() < 0.1,
"expected effective perplexity of ~{perplexity}, got {effective_perplexity}"
);
}
#[test]
fn barnes_hut_separates_clusters_at_large_input_scale() {
const N_PER_CLUSTER: usize = 150;
const DIM: usize = 10;
let mut state = 42_u64;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 33) as f32 / u32::MAX as f32) - 0.5
};
let mut data = Vec::with_capacity(2 * N_PER_CLUSTER * DIM);
for cluster in 0..2 {
let centre = if cluster == 0 { 0.0 } else { 30.0 };
for _ in 0..N_PER_CLUSTER {
for _ in 0..DIM {
data.push(centre + 6.0 * next());
}
}
}
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(30.0)
.epochs(500)
.barnes_hut(THETA, |sample_a, sample_b| {
sample_a
.iter()
.zip(sample_b.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
});
let embedding = tsne.embedding();
let n = 2 * N_PER_CLUSTER;
let mut same_cluster = 0;
for i in 0..n {
let mut best = f32::MAX;
let mut best_j = usize::MAX;
for j in 0..n {
if i == j {
continue;
}
let dx = embedding[2 * i] - embedding[2 * j];
let dy = embedding[2 * i + 1] - embedding[2 * j + 1];
let d = dx * dx + dy * dy;
if d < best {
best = d;
best_j = j;
}
}
if (i < N_PER_CLUSTER) == (best_j < N_PER_CLUSTER) {
same_cluster += 1;
}
}
assert!(
same_cluster as f64 / n as f64 > 0.95,
"clusters not separated: only {same_cluster}/{n} points have a same-cluster nearest neighbor"
);
}
#[test]
fn barnes_hut_is_stable_run_to_run() {
const N: usize = 600;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 11);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let neighbors = brute_force_neighbors(&samples, n_neighbors);
let seed = lcg_samples(N, NO_DIMS as usize, 99);
let run = || {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(150)
.initial_embedding(&seed[..])
.barnes_hut_with_neighbors(THETA, &neighbors);
tsne.embedding().to_vec()
};
let first = run();
let second = run();
let drift = mean_point_distance(&first, &second, NO_DIMS as usize);
let diagonal = bounding_box_diagonal(&first, NO_DIMS as usize);
assert!(
drift <= 0.05 * diagonal + 1e-4,
"two runs diverged: mean drift {drift} exceeds tolerance for diagonal {diagonal}"
);
}
#[test]
fn arena_build_maintains_invariants() {
const N: usize = 2_000;
let mut data = lcg_samples(N, 2, 17);
for value in data.iter_mut() {
*value += 100.0;
}
let arena = barnes_hut_tree::BarnesHutTree::<f32, u64, 2>::new_uniform(&data);
assert_eq!(arena.root_count(), N, "arena lost or invented points");
}
#[test]
fn barnes_hut_does_not_collapse_embedding() {
const N: usize = 500;
const DIM: usize = 8;
let data = lcg_samples(N, DIM, 23);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(30.0)
.epochs(1000)
.barnes_hut(THETA, |a, b| {
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y).powi(2))
.sum::<f32>()
.sqrt()
});
let embedding = tsne.embedding();
let distinct: HashSet<(i64, i64)> = embedding
.chunks_exact(2)
.map(|point| {
(
(point[0] * 100.0).round() as i64,
(point[1] * 100.0).round() as i64,
)
})
.collect();
assert!(
distinct.len() > N / 2,
"embedding collapsed: only {} distinct positions for {N} points",
distinct.len()
);
}
#[test]
fn affinities_round_trip_barnes_hut() {
const N: usize = 200;
let data = lcg_samples(N, D, 42);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let mut tsne1: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne1
.perplexity(PERPLEXITY)
.epochs(EPOCHS)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding1 = tsne1.embedding();
let affinities = tsne1.affinities().expect("should have affinities");
let mut tsne2: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne2
.perplexity(PERPLEXITY)
.epochs(500)
.initial_embedding(embedding1.clone())
.with_affinities(affinities);
tsne2.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding2 = tsne2.embedding();
let mut tsne_rand: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_rand
.perplexity(PERPLEXITY)
.epochs(500)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding_rand = tsne_rand.embedding();
let dist_warm = mean_point_distance(&embedding1, &embedding2, D);
let dist_rand = mean_point_distance(&embedding1, &embedding_rand, D);
assert!(
dist_warm < dist_rand,
"warm start ({}) should be closer to seed than random ({})",
dist_warm,
dist_rand,
);
let data10 = &data[..D * 10];
let dist1 = mean_point_distance(data10, &embedding1[..D * 10], D);
let dist2 = mean_point_distance(data10, &embedding2[..D * 10], D);
assert!(
(dist1 - dist2).abs() < dist1 * 0.5,
"cluster structure changed too much: {} vs {}",
dist1,
dist2,
);
}
#[test]
fn affinities_equivalence_first_step() {
const N: usize = 100;
const CAPTURE: usize = 1;
let data = lcg_samples(N, D, 99);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let mut tsne_ref: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_ref
.perplexity(PERPLEXITY)
.epochs(EPOCHS)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let affinities = tsne_ref.affinities().unwrap();
let seed = tsne_ref.embedding();
let mut tsne_a: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_a
.perplexity(PERPLEXITY)
.epochs(CAPTURE + 1)
.initial_embedding(seed.clone());
tsne_a.barnes_hut(THETA, |a, b| euclidean(a, b));
let result_a = tsne_a.embedding();
let mut tsne_b: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_b
.perplexity(PERPLEXITY)
.epochs(CAPTURE + 1)
.initial_embedding(seed)
.with_affinities(affinities);
tsne_b.barnes_hut(THETA, |a, b| euclidean(a, b));
let result_b = tsne_b.embedding();
let max_diff: f32 = result_a
.iter()
.zip(result_b.iter())
.map(|(a, b)| (a - b).abs())
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap();
let scale = result_a
.iter()
.map(|v| v.abs())
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap();
assert!(
max_diff / scale < 1e-3,
"precomputed path diverged from reference: max_diff={max_diff}, scale={scale}",
);
}
#[test]
fn affinities_pristine_independent_of_run_length() {
const N: usize = 100;
let data = lcg_samples(N, D, 7);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let mut tsne_short: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_short
.perplexity(PERPLEXITY)
.epochs(50)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let affinities_short = tsne_short.affinities().unwrap();
let sum_short: f32 = affinities_short.values.iter().sum();
let mut tsne_long: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_long
.perplexity(PERPLEXITY)
.epochs(1000)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let affinities_long = tsne_long.affinities().unwrap();
let sum_long: f32 = affinities_long.values.iter().sum();
assert!(
(sum_short - 1.0).abs() < 0.05,
"short run affinities sum to {sum_short}, expected ~1",
);
assert!(
(sum_long - 1.0).abs() < 0.05,
"long run affinities sum to {sum_long}, expected ~1",
);
assert_eq!(
affinities_short.rows, affinities_long.rows,
"row structure differs between short and long runs",
);
assert_eq!(
affinities_short.columns, affinities_long.columns,
"column structure differs between short and long runs",
);
for (a, b) in affinities_short
.values
.iter()
.zip(affinities_long.values.iter())
{
assert!((a - b).abs() < 1e-6, "value differs: {} vs {}", a, b,);
}
}
#[test]
fn affinities_work_with_custom_neighbors() {
const N: usize = 100;
let data = lcg_samples(N, D, 42);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let mut tsne_ref: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_ref
.perplexity(PERPLEXITY)
.epochs(EPOCHS)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let affinities = tsne_ref.affinities().unwrap();
let seed = tsne_ref.embedding();
let mut rng = rand::rng();
let different_neighbors: Vec<Vec<Neighbor<f32>>> = samples
.iter()
.enumerate()
.map(|(sample_idx, _)| {
let mut row: Vec<Neighbor<f32>> = (0..N)
.filter_map(|i| {
if i == sample_idx {
None
} else {
Some(Neighbor {
index: i,
distance: rng.random_range(0.0..100.0),
})
}
})
.collect();
row.truncate(15);
row
})
.collect();
let mut tsne_b: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_b
.perplexity(PERPLEXITY)
.epochs(50)
.initial_embedding(seed.clone())
.with_affinities(affinities.clone());
tsne_b.barnes_hut_with_neighbors(THETA, &different_neighbors);
let result_b = tsne_b.embedding();
let mut tsne_c: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_c
.perplexity(PERPLEXITY)
.epochs(50)
.initial_embedding(seed)
.with_affinities(affinities);
tsne_c.barnes_hut(THETA, |a, b| euclidean(a, b));
let result_c = tsne_c.embedding();
let max_diff: f32 = result_b
.iter()
.zip(result_c.iter())
.map(|(a, b)| (a - b).abs())
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap();
let scale = result_b
.iter()
.map(|v| v.abs())
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap();
assert!(
max_diff / scale > 0.05,
"different neighbors should invalidate cached affinities: max_diff={}, scale={}",
max_diff,
scale,
);
}
#[test]
fn cached_affinities_random_seed() {
const N: usize = 50;
let data = lcg_samples(N, D, 7);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let mut tsne_ref: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_ref
.perplexity(PERPLEXITY)
.epochs(EPOCHS)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let affinities = tsne_ref.affinities().unwrap();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(50)
.with_affinities(affinities);
tsne.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne.embedding();
assert_eq!(embedding.len(), N * 2);
assert!(
embedding.iter().all(|v| v.is_finite()),
"embedding contains non-finite values",
);
assert!(
embedding.iter().any(|v| *v != 0.0),
"embedding is all zeros, random init may have failed",
);
}
#[test]
fn cached_affinities_discarded_on_dataset_mismatch() {
const N_SMALL: usize = 50;
const N_LARGE: usize = 100;
let data_small = lcg_samples(N_SMALL, D, 7);
let samples_small: Vec<&[f32]> = data_small.chunks(D).collect();
let mut tsne_small: tSNE<f32, &[f32]> = tSNE::new(&samples_small);
tsne_small
.perplexity(PERPLEXITY)
.epochs(EPOCHS)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let affinities = tsne_small.affinities().unwrap();
let data_large = lcg_samples(N_LARGE, D, 7);
let samples_large: Vec<&[f32]> = data_large.chunks(D).collect();
let mut tsne_large: tSNE<f32, &[f32]> = tSNE::new(&samples_large);
tsne_large
.perplexity(PERPLEXITY)
.with_affinities(affinities);
tsne_large.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne_large.embedding();
assert_eq!(embedding.len(), N_LARGE * 2);
assert!(
embedding.iter().all(|v| v.is_finite()),
"embedding contains non-finite values",
);
}
#[test]
fn cached_affinities_invalidated_on_perplexity_change() {
const N: usize = 80;
let data = lcg_samples(N, D, 42);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let new_perplexity = 2.0_f32;
let n_neighbors = (3.0 * new_perplexity) as usize;
let neighbors = brute_force_neighbors(&samples, n_neighbors);
let mut tsne_seed: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_seed.perplexity(PERPLEXITY).epochs(20);
tsne_seed.barnes_hut(THETA, |a, b| euclidean(a, b));
let seed = tsne_seed.embedding();
let mut tsne_ref: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_ref.perplexity(new_perplexity);
tsne_ref.barnes_hut_with_neighbors(THETA, &neighbors);
let affinities = tsne_ref.affinities().unwrap();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.unwrap();
let (result_a, result_b) = pool.install(|| {
let mut tsne_a: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_a
.perplexity(new_perplexity)
.with_affinities(affinities.clone())
.epochs(50)
.initial_embedding(seed.clone());
tsne_a.perplexity(PERPLEXITY);
tsne_a.barnes_hut_with_neighbors(THETA, &neighbors);
let result_a = tsne_a.embedding();
let mut tsne_b: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_b
.perplexity(new_perplexity)
.with_affinities(affinities)
.epochs(50)
.initial_embedding(seed);
tsne_b.barnes_hut_with_neighbors(THETA, &neighbors);
let result_b = tsne_b.embedding();
(result_a, result_b)
});
let any_diff = result_a
.iter()
.zip(result_b.iter())
.any(|(a, b)| (a - b).abs() > 1e-6);
assert!(
any_diff,
"embeddings are identical: perplexity change did not invalidate cache",
);
}
#[test]
fn cached_affinities_reset_stop_lying_flag() {
const N: usize = 80;
const SL_EPOCH: usize = 5;
const RUN_EPOCHS: usize = 20;
let data = lcg_samples(N, D, 42);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let neighbors = brute_force_neighbors(&samples, n_neighbors);
let mut tsne_ref: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_ref.perplexity(PERPLEXITY);
tsne_ref.barnes_hut_with_neighbors(THETA, &neighbors);
let affinities = tsne_ref.affinities().unwrap();
let mut tsne_seed: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_seed.perplexity(PERPLEXITY).epochs(20);
tsne_seed.barnes_hut(THETA, |a, b| euclidean(a, b));
let seed = tsne_seed.embedding();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.unwrap();
let (result_a, result_b) = pool.install(|| {
let mut tsne_a: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_a
.perplexity(PERPLEXITY)
.with_affinities(affinities.clone())
.stop_lying_epoch(SL_EPOCH)
.epochs(RUN_EPOCHS)
.initial_embedding(seed.clone());
tsne_a.barnes_hut_with_neighbors(THETA, &neighbors);
let result_a = tsne_a.embedding();
let mut tsne_b: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne_b
.perplexity(PERPLEXITY)
.stop_lying_epoch(SL_EPOCH)
.epochs(RUN_EPOCHS);
tsne_b.barnes_hut_with_neighbors(THETA, &neighbors);
tsne_b.epochs(RUN_EPOCHS).initial_embedding(seed);
tsne_b.barnes_hut_with_neighbors(THETA, &neighbors);
let result_b = tsne_b.embedding();
(result_a, result_b)
});
assert_eq!(
result_a, result_b,
"second cached-affinities run produced different embedding: stop_lying_fired was not reset",
);
}
#[test]
fn with_affinities_adopts_perplexity_from_affinities() {
let data: Vec<f32> = (0..800).map(|i| i as f32).collect();
let samples: Vec<&[f32]> = data.chunks(4).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(30.0).epochs(20);
tsne.barnes_hut(THETA, |a, b| euclidean(a, b));
let affinities = tsne.affinities().unwrap();
let mut tsne2: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne2.perplexity(5.0);
tsne2.with_affinities(affinities);
assert_eq!(tsne2.perplexity, 30.0);
}
#[test]
fn kl_divergence_after_fit_sne_is_finite_and_nonnegative() {
const N: usize = 200;
let data = lcg_samples(N, D, 11);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(250)
.fit_sne(|a, b| euclidean(a, b));
let kl = tsne.kl_divergence().expect("fitted");
assert!(kl.is_finite() && kl >= 0.0, "kl divergence was {kl}");
}
#[test]
fn fit_sne_separates_clusters() {
const N_PER_CLUSTER: usize = 250;
const DIM: usize = 10;
let mut state = 1234_u64;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 33) as f32 / u32::MAX as f32) - 0.5
};
let mut data = Vec::with_capacity(2 * N_PER_CLUSTER * DIM);
for cluster in 0..2 {
let centre = if cluster == 0 { 0.0 } else { 30.0 };
for _ in 0..N_PER_CLUSTER {
for _ in 0..DIM {
data.push(centre + 6.0 * next());
}
}
}
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(30.0)
.epochs(500)
.fit_sne(|a, b| euclidean(a, b));
let embedding = tsne.embedding();
let n = 2 * N_PER_CLUSTER;
let mut same_cluster = 0;
for i in 0..n {
let mut best = f32::MAX;
let mut best_j = usize::MAX;
for j in 0..n {
if i == j {
continue;
}
let dx = embedding[2 * i] - embedding[2 * j];
let dy = embedding[2 * i + 1] - embedding[2 * j + 1];
let d = dx * dx + dy * dy;
if d < best {
best = d;
best_j = j;
}
}
if (i < N_PER_CLUSTER) == (best_j < N_PER_CLUSTER) {
same_cluster += 1;
}
}
assert!(
same_cluster as f64 / n as f64 > 0.95,
"clusters not separated: only {same_cluster}/{n} points have a same-cluster nearest neighbor"
);
}
#[test]
fn fit_sne_does_not_collapse_embedding() {
const N: usize = 400;
const DIM: usize = 8;
let data = lcg_samples(N, DIM, 23);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(30.0)
.epochs(500)
.fit_sne(|a, b| euclidean(a, b));
let embedding = tsne.embedding();
assert!(
embedding.iter().all(|v| v.is_finite()),
"embedding contains non-finite values"
);
let distinct: HashSet<(i64, i64)> = embedding
.chunks_exact(2)
.map(|point| {
(
(point[0] * 100.0).round() as i64,
(point[1] * 100.0).round() as i64,
)
})
.collect();
assert!(
distinct.len() > N / 2,
"embedding collapsed: only {} distinct positions for {N} points",
distinct.len()
);
}
#[test]
fn fit_sne_with_neighbors_matches_vptree_path() {
const N: usize = 120;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 11);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let seed = lcg_samples(N, NO_DIMS as usize, 99);
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let neighbors = brute_force_neighbors(&samples, n_neighbors);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(1)
.build()
.unwrap();
let (reference, candidate) = pool.install(|| {
let reference = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(100)
.initial_embedding(&seed[..])
.fit_sne(|a, b| euclidean(a, b));
tsne.embedding()
};
let candidate = {
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(100)
.initial_embedding(&seed[..])
.fit_sne_with_neighbors(&neighbors);
tsne.embedding()
};
(reference, candidate)
});
assert_eq!(candidate, reference);
}
#[test]
#[should_panic(expected = "same length")]
fn fit_sne_with_neighbors_rejects_ragged_rows() {
const N: usize = 80;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 11);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let mut neighbors = brute_force_neighbors(&samples, n_neighbors);
neighbors[0].pop();
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.fit_sne_with_neighbors(&neighbors);
}
#[test]
#[should_panic(expected = "out of range")]
fn fit_sne_with_neighbors_rejects_out_of_range_index() {
const N: usize = 80;
const DIM: usize = 4;
let data = lcg_samples(N, DIM, 11);
let samples: Vec<&[f32]> = data.chunks(DIM).collect();
let n_neighbors = (3.0 * PERPLEXITY) as usize;
let mut neighbors = brute_force_neighbors(&samples, n_neighbors);
neighbors[0][0].index = N;
let mut tsne: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(1)
.fit_sne_with_neighbors(&neighbors);
}
#[test]
fn barnes_hut_runs_in_four_dimensions() {
const N: usize = 200;
const DIN: usize = 5;
let data = lcg_samples(N, DIN, 42);
let samples: Vec<&[f32]> = data.chunks(DIN).collect();
let mut tsne: tSNE<f32, &[f32], 4> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(50)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne.embedding();
assert_eq!(embedding.len(), N * 4);
assert!(embedding.iter().all(|v| v.is_finite()));
}
#[test]
fn barnes_hut_runs_in_three_dimensions() {
const N: usize = 200;
const DIN: usize = 5;
let data = lcg_samples(N, DIN, 43);
let samples: Vec<&[f32]> = data.chunks(DIN).collect();
let mut tsne: tSNE<f32, &[f32], 3> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(50)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne.embedding();
assert_eq!(embedding.len(), N * 3);
assert!(embedding.iter().all(|v| v.is_finite()));
}
#[test]
fn barnes_hut_runs_in_five_dimensions() {
const N: usize = 200;
const DIN: usize = 6;
let data = lcg_samples(N, DIN, 44);
let samples: Vec<&[f32]> = data.chunks(DIN).collect();
let mut tsne: tSNE<f32, &[f32], 5> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(50)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne.embedding();
assert_eq!(embedding.len(), N * 5);
assert!(embedding.iter().all(|v| v.is_finite()));
}
#[test]
fn barnes_hut_runs_in_six_dimensions() {
const N: usize = 200;
const DIN: usize = 7;
let data = lcg_samples(N, DIN, 45);
let samples: Vec<&[f32]> = data.chunks(DIN).collect();
let mut tsne: tSNE<f32, &[f32], 6> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(50)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne.embedding();
assert_eq!(embedding.len(), N * 6);
assert!(embedding.iter().all(|v| v.is_finite()));
}
#[test]
fn barnes_hut_runs_in_seven_dimensions() {
const N: usize = 200;
const DIN: usize = 8;
let data = lcg_samples(N, DIN, 46);
let samples: Vec<&[f32]> = data.chunks(DIN).collect();
let mut tsne: tSNE<f32, &[f32], 7> = tSNE::new(&samples);
tsne.perplexity(PERPLEXITY)
.epochs(50)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne.embedding();
assert_eq!(embedding.len(), N * 7);
assert!(embedding.iter().all(|v| v.is_finite()));
}
fn two_block_affinities(half: usize) -> SparseAffinities<f32> {
let n = half * 2;
let mut rows: Vec<usize> = Vec::with_capacity(n + 1);
let mut columns: Vec<u32> = Vec::new();
let mut values: Vec<f32> = Vec::new();
rows.push(0);
for i in 0..n {
if i == 0 {
for j in 1..half {
columns.push(j as u32);
values.push(1.0);
}
columns.push(half as u32);
values.push(0.01);
} else if i == half {
columns.push(0);
values.push(0.01);
for j in (half + 1)..n {
columns.push(j as u32);
values.push(1.0);
}
} else if i < half {
for j in 0..half {
if j != i {
columns.push(j as u32);
values.push(1.0);
}
}
} else {
for j in half..n {
if j != i {
columns.push(j as u32);
values.push(1.0);
}
}
}
rows.push(columns.len());
}
SparseAffinities {
rows,
columns,
values,
perplexity: 5.0,
}
}
#[test]
fn spectral_init_separates_two_blocks() {
const HALF: usize = 20;
let affinities = two_block_affinities(HALF);
let n = HALF * 2;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 1> = tSNE::new(&samples);
tsne.with_affinities(affinities);
let seed = tsne.spectral_embedding();
let first_half_positive = seed[..HALF].iter().filter(|&&v| v > 0.0).count();
let first_half_negative = seed[..HALF].iter().filter(|&&v| v < 0.0).count();
let second_half_positive = seed[HALF..].iter().filter(|&&v| v > 0.0).count();
let second_half_negative = seed[HALF..].iter().filter(|&&v| v < 0.0).count();
let first_dominant = first_half_positive > first_half_negative;
let second_dominant = second_half_positive > second_half_negative;
assert!(
first_dominant != second_dominant,
"spectral init did not separate blocks: first half {{pos: {}, neg: {}}}, second half {{pos: {}, neg: {}}}",
first_half_positive,
first_half_negative,
second_half_positive,
second_half_negative
);
}
#[test]
fn spectral_init_is_deterministic() {
let affinities = two_block_affinities(15);
let n = 30;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne_a: tSNE<f32, &[f32], 3> = tSNE::new(&samples);
tsne_a.with_affinities(affinities.clone());
let seed_a = tsne_a.spectral_embedding();
let mut tsne_b: tSNE<f32, &[f32], 3> = tSNE::new(&samples);
tsne_b.with_affinities(affinities);
let seed_b = tsne_b.spectral_embedding();
assert_eq!(seed_a, seed_b, "spectral init is not deterministic");
}
#[test]
fn spectral_init_shape_and_finiteness() {
let affinities = two_block_affinities(10);
let n = 20;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
{
let mut tsne: tSNE<f32, &[f32], 2> = tSNE::new(&samples);
tsne.with_affinities(affinities.clone());
let seed = tsne.spectral_embedding();
assert_eq!(seed.len(), n * 2);
assert!(seed.iter().all(|v| v.is_finite()));
}
{
let mut tsne: tSNE<f32, &[f32], 4> = tSNE::new(&samples);
tsne.with_affinities(affinities.clone());
let seed = tsne.spectral_embedding();
assert_eq!(seed.len(), n * 4);
assert!(seed.iter().all(|v| v.is_finite()));
}
{
let mut tsne: tSNE<f32, &[f32], 7> = tSNE::new(&samples);
tsne.with_affinities(affinities);
let seed = tsne.spectral_embedding();
assert_eq!(seed.len(), n * 7);
assert!(seed.iter().all(|v| v.is_finite()));
}
}
#[test]
fn spectral_init_scale_matches_random_init() {
let affinities = two_block_affinities(15);
let n = 30;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 2> = tSNE::new(&samples);
tsne.with_affinities(affinities);
let seed = tsne.spectral_embedding();
let col0: Vec<f32> = (0..n).map(|i| seed[i * 2]).collect();
let mean: f32 = col0.iter().sum::<f32>() / n as f32;
let variance: f32 = col0.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / n as f32;
let std = variance.sqrt();
assert!(
(std - 1e-4).abs() < 1e-6,
"first column std is {std}, expected ~1e-4"
);
}
fn affinities_with_isolated_nodes() -> SparseAffinities<f32> {
let n = 6;
let mut rows = vec![0usize; n + 1];
let mut columns = Vec::new();
let mut values = Vec::new();
for i in 0..3 {
for j in 0..3 {
if i != j {
columns.push(j as u32);
values.push(1.0);
}
}
rows[i + 1] = columns.len();
}
for i in 3..5 {
for j in 3..5 {
if i != j {
columns.push(j as u32);
values.push(1.0);
}
}
rows[i + 1] = columns.len();
}
rows[6] = columns.len();
SparseAffinities {
rows,
columns,
values,
perplexity: 2.0,
}
}
#[test]
fn spectral_init_handles_isolated_nodes() {
let affinities = affinities_with_isolated_nodes();
let n = 6;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 2> = tSNE::new(&samples);
tsne.with_affinities(affinities);
let seed = tsne.spectral_embedding();
assert_eq!(seed.len(), n * 2);
assert!(
seed.iter().all(|v| v.is_finite()),
"spectral init with isolated nodes produced non-finite values"
);
}
#[test]
fn spectral_init_through_initial_embedding_reduces_kl() {
const N: usize = 30;
let data = lcg_samples(N, D, 42);
let samples: Vec<&[f32]> = data.chunks(D).collect();
let mut tsne1: tSNE<f32, &[f32]> = tSNE::new(&samples);
tsne1
.perplexity(5.0)
.epochs(100)
.barnes_hut(THETA, |a, b| euclidean(a, b));
let seed = tsne1.spectral_embedding();
assert_eq!(seed.len(), N * 2);
tsne1.perplexity(5.0).epochs(50).initial_embedding(seed);
tsne1.barnes_hut(THETA, |a, b| euclidean(a, b));
let embedding = tsne1.embedding();
assert_eq!(embedding.len(), N * 2);
assert!(embedding.iter().all(|v| v.is_finite()));
assert!(
tsne1
.kl_divergence()
.expect("spectral fit should have KL")
.is_finite()
);
}
#[test]
fn spectral_init_via_builder_separates_blocks() {
const HALF: usize = 20;
let affinities = two_block_affinities(HALF);
let n = HALF * 2;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 2> = tSNE::new(&samples);
tsne.with_affinities(affinities).spectral_init();
tsne.epochs(0).barnes_hut(0.5, |_, _| 0.0);
let embedding = tsne.embedding();
let col0: Vec<f32> = embedding.iter().step_by(2).cloned().collect();
let first_half_positive = col0[..HALF].iter().filter(|&&v| v > 0.0).count();
let first_half_negative = col0[..HALF].iter().filter(|&&v| v < 0.0).count();
let second_half_positive = col0[HALF..].iter().filter(|&&v| v > 0.0).count();
let second_half_negative = col0[HALF..].iter().filter(|&&v| v < 0.0).count();
let first_dominant = first_half_positive > first_half_negative;
let second_dominant = second_half_positive > second_half_negative;
assert!(
first_dominant != second_dominant,
"spectral init via builder did not separate blocks"
);
}
#[test]
fn explicit_initial_embedding_overrides_spectral_init() {
const HALF: usize = 20;
let affinities = two_block_affinities(HALF);
let n = HALF * 2;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 2> = tSNE::new(&samples);
let explicit: Vec<f32> = (0..n * 2).map(|i| i as f32 * 0.001).collect();
tsne.with_affinities(affinities)
.spectral_init()
.initial_embedding(explicit.clone());
tsne.epochs(0).barnes_hut(0.5, |_, _| 0.0);
let embedding = tsne.embedding();
assert_eq!(embedding, explicit);
}
#[test]
fn spectral_init_with_custom_params_separates_blocks() {
const HALF: usize = 20;
let affinities = two_block_affinities(HALF);
let n = HALF * 2;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 1> = tSNE::new(&samples);
tsne.with_affinities(affinities);
let params = SpectralParams::new().rounds(3).degree(10);
let seed = tsne.spectral_embedding_with(params);
let first_half_positive = seed[..HALF].iter().filter(|&&v| v > 0.0).count();
let second_half_positive = seed[HALF..].iter().filter(|&&v| v > 0.0).count();
let first_dominant = first_half_positive > HALF / 2;
let second_dominant = second_half_positive > HALF / 2;
assert!(
first_dominant != second_dominant,
"custom-parameter spectral init did not separate blocks"
);
assert_eq!(seed, tsne.spectral_embedding_with(params));
assert_ne!(seed, tsne.spectral_embedding());
}
#[test]
fn spectral_init_with_custom_seed_std_scales_columns() {
let affinities = two_block_affinities(15);
let n = 30;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 2> = tSNE::new(&samples);
tsne.with_affinities(affinities);
let seed = tsne.spectral_embedding_with(SpectralParams::new().seed_std(2e-3));
let col0: Vec<f32> = (0..n).map(|i| seed[i * 2]).collect();
let mean: f32 = col0.iter().sum::<f32>() / n as f32;
let variance: f32 = col0.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / n as f32;
let std = variance.sqrt();
assert!(
(std - 2e-3).abs() < 2e-5,
"first column std is {std}, expected ~2e-3"
);
}
#[test]
fn spectral_init_with_flows_through_builder() {
const HALF: usize = 20;
let affinities = two_block_affinities(HALF);
let n = HALF * 2;
let data: Vec<f32> = vec![0.0; n];
let samples: Vec<&[f32]> = data.chunks(1).collect();
let mut tsne: tSNE<f32, &[f32], 2> = tSNE::new(&samples);
tsne.with_affinities(affinities)
.spectral_init_with(SpectralParams::new().seed_std(5e-3));
tsne.epochs(0).barnes_hut(0.5, |_, _| 0.0);
let embedding = tsne.embedding();
let col0: Vec<f32> = embedding.iter().step_by(2).cloned().collect();
let mean: f32 = col0.iter().sum::<f32>() / n as f32;
let variance: f32 = col0.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / n as f32;
let std = variance.sqrt();
assert!(
(std - 5e-3).abs() < 5e-5,
"first column std is {std}, expected ~5e-3"
);
}
#[test]
#[should_panic(expected = "at least one spectral solver round")]
fn spectral_params_reject_zero_rounds() {
let _ = SpectralParams::new().rounds(0);
}
#[test]
#[should_panic(expected = "Chebyshev filter degree")]
fn spectral_params_reject_zero_degree() {
let _ = SpectralParams::new().degree(0);
}
#[test]
#[should_panic(expected = "seed standard deviation")]
fn spectral_params_reject_nonpositive_seed_std() {
let _ = SpectralParams::new().seed_std(0.0);
}
mod spectral_properties {
use proptest::prelude::*;
use super::super::tsne::spectral::{SpectralParams, spectral_embedding};
fn run_embedding(
rows: &[usize],
columns: &[u32],
values: &[f32],
d_out: usize,
params: SpectralParams,
) -> Vec<f32> {
match d_out {
1 => spectral_embedding::<f32, 1>(rows, columns, values, params),
2 => spectral_embedding::<f32, 2>(rows, columns, values, params),
3 => spectral_embedding::<f32, 3>(rows, columns, values, params),
4 => spectral_embedding::<f32, 4>(rows, columns, values, params),
_ => unreachable!("the generators only produce d_out 1 through 4"),
}
}
fn symmetric_csr(n: usize, edges: &[(usize, usize, f32)]) -> (Vec<usize>, Vec<u32>, Vec<f32>) {
let mut weights = vec![0.0f32; n * n];
for &(a, b, weight) in edges {
let (i, j) = (a % n, b % n);
if i == j {
continue;
}
weights[i * n + j] += weight;
weights[j * n + i] += weight;
}
let mut rows = vec![0usize];
let mut columns = Vec::new();
let mut values = Vec::new();
for i in 0..n {
for j in 0..n {
if weights[i * n + j] > 0.0 {
columns.push(j as u32);
values.push(weights[i * n + j]);
}
}
rows.push(columns.len());
}
(rows, columns, values)
}
fn two_clique_csr(
size_a: usize,
size_b: usize,
w_a: f32,
w_b: f32,
) -> (Vec<usize>, Vec<u32>, Vec<f32>) {
let n = size_a + size_b;
let mut edges = Vec::new();
for i in 0..size_a {
for j in (i + 1)..size_a {
edges.push((i, j, w_a));
}
}
for i in size_a..n {
for j in (i + 1)..n {
edges.push((i, j, w_b));
}
}
symmetric_csr(n, &edges)
}
proptest::proptest! {
#[test]
fn embedding_contract_holds_on_arbitrary_graphs(
(n, edges, d_out) in (1usize..=40).prop_flat_map(|n| {
(
Just(n),
proptest::collection::vec((0..n, 0..n, 0.01f32..10.0), 0..4 * n),
1usize..=4,
)
}),
) {
let (rows, columns, values) = symmetric_csr(n, &edges);
let params = SpectralParams::default();
let embedding = run_embedding(&rows, &columns, &values, d_out, params);
prop_assert_eq!(embedding.len(), n * d_out);
prop_assert!(embedding.iter().all(|v| v.is_finite()));
for d in 0..d_out {
let column: Vec<f32> = (0..n).map(|i| embedding[i * d_out + d]).collect();
let mean = column.iter().sum::<f32>() / n as f32;
prop_assert!(mean.abs() < 5e-6, "column {d} mean {mean} is not ~0");
let std = (column.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>()
/ n as f32)
.sqrt();
prop_assert!(
std < 1e-25 || (std - 1e-4).abs() < 2e-6,
"column {d} std {std} is neither ~1e-4 nor degenerate"
);
}
let again = run_embedding(&rows, &columns, &values, d_out, params);
prop_assert_eq!(embedding, again);
}
#[test]
fn embedding_separates_disconnected_cliques(
size_a in 3usize..=20,
size_b in 3usize..=20,
w_a in 0.05f32..5.0,
w_b in 0.05f32..5.0,
) {
let (rows, columns, values) = two_clique_csr(size_a, size_b, w_a, w_b);
let embedding =
run_embedding(&rows, &columns, &values, 1, SpectralParams::default());
let sign_a = embedding[0] > 0.0;
prop_assert!(
embedding[..size_a].iter().all(|&v| (v > 0.0) == sign_a && v != 0.0),
"first clique is not on one strict side of zero"
);
prop_assert!(
embedding[size_a..].iter().all(|&v| (v > 0.0) != sign_a && v != 0.0),
"second clique is not strictly on the opposite side"
);
}
#[test]
fn embedding_contract_holds_for_any_params(
rounds in 1usize..=6,
degree in 1usize..=25,
seed_std in 1e-6f64..1e-2,
) {
let (rows, columns, values) = two_clique_csr(12, 9, 1.0, 0.5);
let n = 21;
let params = SpectralParams::new()
.rounds(rounds)
.degree(degree)
.seed_std(seed_std);
let embedding = run_embedding(&rows, &columns, &values, 2, params);
prop_assert_eq!(embedding.len(), n * 2);
prop_assert!(embedding.iter().all(|v: &f32| v.is_finite()));
for d in 0..2 {
let column: Vec<f32> = (0..n).map(|i| embedding[i * 2 + d]).collect();
let mean = column.iter().sum::<f32>() / n as f32;
let std = (column.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>()
/ n as f32)
.sqrt();
let target = seed_std as f32;
prop_assert!(
std < 1e-12 || (std - target).abs() < target * 0.02,
"column {d} std {std} does not match requested {target}"
);
}
let again = run_embedding(&rows, &columns, &values, 2, params);
prop_assert_eq!(embedding, again);
}
}
}