use std::{
iter::Sum,
ops::{AddAssign, DivAssign, MulAssign, Range, SubAssign},
};
use num_traits::Float;
use rayon::{
iter::{
IndexedParallelIterator, IntoParallelIterator, IntoParallelRefMutIterator, ParallelIterator,
},
slice::ParallelSliceMut,
};
use barnes_hut_tree::Dim;
mod sealed {
pub trait Sealed {}
}
pub trait SpectralBlock: sealed::Sealed {
const WIDTH: usize;
type Row<T: Float + Default + Send + Sync>: AsRef<[T]> + AsMut<[T]> + Default + Send + Sync;
fn as_rows<T: Float + Default + Send + Sync>(flat: &[T]) -> &[Self::Row<T>];
fn as_rows_mut<T: Float + Default + Send + Sync>(flat: &mut [T]) -> &mut [Self::Row<T>];
}
macro_rules! spectral_block {
($(($dim:literal, $width:literal)),* $(,)?) => {$(
// The row width must stay in lockstep with the oversampling.
const _: () = assert!($dim + SPECTRAL_OVERSAMPLE == $width);
impl sealed::Sealed for Dim<$dim> {}
impl SpectralBlock for Dim<$dim> {
const WIDTH: usize = $width;
type Row<T: Float + Default + Send + Sync> = [T; $width];
#[inline]
fn as_rows<T: Float + Default + Send + Sync>(flat: &[T]) -> &[[T; $width]] {
flat.as_chunks::<$width>().0
}
#[inline]
fn as_rows_mut<T: Float + Default + Send + Sync>(flat: &mut [T]) -> &mut [[T; $width]] {
flat.as_chunks_mut::<$width>().0
}
}
)*};
}
spectral_block!((1, 9), (2, 10), (3, 11), (4, 12), (5, 13), (6, 14), (7, 15),);
const SPECTRAL_OVERSAMPLE: usize = 8;
const DEFAULT_ROUNDS: usize = 5;
const DEFAULT_DEGREE: usize = 20;
const DEFAULT_SEED_STD: f64 = 1e-4;
const CHEBYSHEV_INITIAL_BOUND: f64 = 0.75;
const REDUCTION_ROWS: usize = 4096;
#[derive(Clone, Copy, Debug)]
pub struct SpectralParams {
pub(crate) rounds: usize,
pub(crate) degree: usize,
pub(crate) seed_std: f64,
}
impl Default for SpectralParams {
fn default() -> Self {
Self {
rounds: DEFAULT_ROUNDS,
degree: DEFAULT_DEGREE,
seed_std: DEFAULT_SEED_STD,
}
}
}
impl SpectralParams {
pub fn new() -> Self {
Self::default()
}
pub fn rounds(mut self, rounds: usize) -> Self {
assert!(
rounds >= 1,
"at least one spectral solver round is required"
);
self.rounds = rounds;
self
}
pub fn degree(mut self, degree: usize) -> Self {
assert!(
degree >= 1,
"the Chebyshev filter degree must be at least 1"
);
self.degree = degree;
self
}
pub fn seed_std(mut self, seed_std: f64) -> Self {
assert!(
seed_std.is_finite() && seed_std > 0.0,
"the seed standard deviation must be strictly positive and finite"
);
self.seed_std = seed_std;
self
}
}
#[allow(clippy::needless_range_loop)]
pub(crate) fn spectral_embedding<T, const D: usize>(
p_rows: &[usize],
p_columns: &[u32],
p_values: &[T],
params: SpectralParams,
) -> Vec<T>
where
T: Float + Default + Sum + AddAssign + SubAssign + MulAssign + DivAssign + Send + Sync,
Dim<D>: SpectralBlock,
{
let d_out = D;
let n = p_rows.len().saturating_sub(1);
assert!(
n > 0,
"the spectral embedding requires affinities to be built"
);
let one = T::one();
let degrees: Vec<T> = (0..n)
.map(|i| p_values[p_rows[i]..p_rows[i + 1]].iter().copied().sum())
.collect();
let floor = T::from(1e-12).unwrap();
let inv_sqrt_d: Vec<T> = degrees
.iter()
.map(|&d| {
let s = d.sqrt();
if s > floor { one / s } else { floor }
})
.collect();
let inv_sqrt_d_ref = &inv_sqrt_d;
let sorted_rows: Vec<Vec<(u32, T)>> = (0..n)
.into_par_iter()
.map(|i| {
let mut pairs: Vec<(u32, T)> = p_columns[p_rows[i]..p_rows[i + 1]]
.iter()
.zip(p_values[p_rows[i]..p_rows[i + 1]].iter())
.map(|(&col, &val)| (col, val * inv_sqrt_d_ref[col as usize]))
.collect();
pairs.sort_unstable_by_key(|&(col, _)| col);
pairs
})
.collect();
let mut edge_cols: Vec<u32> = Vec::with_capacity(p_columns.len());
let mut edge_weights: Vec<T> = Vec::with_capacity(p_columns.len());
for row in &sorted_rows {
for &(col, weight) in row {
edge_cols.push(col);
edge_weights.push(weight);
}
}
let sqrt_d: Vec<T> = degrees.iter().map(|d| d.sqrt()).collect();
let norm_v0 = sqrt_d.iter().map(|v| *v * *v).sum::<T>().sqrt();
let v0: Vec<T> = if norm_v0 > T::zero() {
sqrt_d.iter().map(|v| *v / norm_v0).collect()
} else {
vec![T::zero(); n]
};
let mut v = chebyshev_rayleigh_ritz::<T, Dim<D>>(
n,
&v0,
&inv_sqrt_d,
p_rows,
&edge_cols,
&edge_weights,
params,
);
v.par_chunks_mut(d_out).enumerate().for_each(|(i, row)| {
for d in 0..d_out {
row[d] *= inv_sqrt_d[i];
}
});
let n_t = T::from(n as f64).unwrap();
let sums = chunked_column_reduce(n, d_out, |range, acc: &mut [T]| {
for i in range {
let row = &v[i * d_out..(i + 1) * d_out];
for d in 0..d_out {
acc[d] += row[d];
}
}
});
v.par_chunks_mut(d_out).for_each(|row| {
for d in 0..d_out {
row[d] -= sums[d] / n_t;
}
});
let target_std = T::from(params.seed_std).unwrap();
let var_sums = chunked_column_reduce(n, d_out, |range, acc: &mut [T]| {
for i in range {
let row = &v[i * d_out..(i + 1) * d_out];
for d in 0..d_out {
acc[d] += row[d] * row[d];
}
}
});
let scales: Vec<T> = var_sums
.iter()
.map(|&var_sum| {
let std_d = (var_sum / n_t).sqrt();
if std_d > T::from(1e-30).unwrap() {
target_std / std_d
} else {
T::one()
}
})
.collect();
v.par_chunks_mut(d_out).for_each(|row| {
for d in 0..d_out {
row[d] *= scales[d];
}
});
v
}
#[allow(clippy::too_many_arguments)]
fn chebyshev_rayleigh_ritz<T, S>(
n: usize,
v0: &[T],
inv_sqrt_d: &[T],
p_rows: &[usize],
edge_cols: &[u32],
edge_weights: &[T],
params: SpectralParams,
) -> Vec<T>
where
T: Float + Default + Sum + AddAssign + SubAssign + DivAssign + Send + Sync,
S: SpectralBlock,
{
assert!(
params.rounds >= 1
&& params.degree >= 1
&& params.seed_std.is_finite()
&& params.seed_std > 0.0
);
let k = S::WIDTH;
let d_out = S::WIDTH - SPECTRAL_OVERSAMPLE;
let zero = T::zero();
let one = T::one();
let two = one + one;
let mut v: Vec<T> = (0..n * k)
.map(|index| splitmix_unit::<T>(index as u64))
.collect();
let mut y_prev = vec![zero; n * k];
let mut y_next = vec![zero; n * k];
let mut ritz_vectors: Vec<T> = Vec::new();
let mut ritz_order: Vec<usize> = Vec::new();
let mut bound = T::from(CHEBYSHEV_INITIAL_BOUND).unwrap();
for round in 0..params.rounds {
let t1 = (two - bound) / bound;
let mut alpha = one / t1;
y_prev.copy_from_slice(&v);
matvec_combine::<T, S>(
&y_prev,
&y_prev,
&mut v,
inv_sqrt_d,
p_rows,
edge_cols,
edge_weights,
two * alpha / bound,
-alpha,
zero,
);
for _ in 2..=params.degree {
let alpha_next = one / (two * t1 - alpha);
matvec_combine::<T, S>(
&v,
&y_prev,
&mut y_next,
inv_sqrt_d,
p_rows,
edge_cols,
edge_weights,
two * two * alpha_next / bound,
-two * alpha_next,
-alpha_next * alpha,
);
std::mem::swap(&mut y_prev, &mut v);
std::mem::swap(&mut v, &mut y_next);
alpha = alpha_next;
}
let refresh_seed = (round as u64 + 1) * n as u64 * k as u64;
orthonormalize_block(&mut v, n, k, v0, refresh_seed);
matvec_combine::<T, S>(
&v,
&v,
&mut y_next,
inv_sqrt_d,
p_rows,
edge_cols,
edge_weights,
one,
zero,
zero,
);
let mut b = chunked_column_reduce(n, k * k, |range, acc: &mut [T]| {
for i in range {
let v_row = &v[i * k..(i + 1) * k];
let w_row = &y_next[i * k..(i + 1) * k];
for p in 0..k {
for q in 0..k {
acc[p * k + q] += v_row[p] * w_row[q];
}
}
}
});
for p in 0..k {
for q in (p + 1)..k {
let mean = (b[p * k + q] + b[q * k + p]) / two;
b[p * k + q] = mean;
b[q * k + p] = mean;
}
}
let s = jacobi_eigen(&mut b, k);
let mut order: Vec<usize> = (0..k).collect();
order.sort_unstable_by(|&lhs, &rhs| {
b[rhs * k + rhs]
.partial_cmp(&b[lhs * k + lhs])
.unwrap_or(core::cmp::Ordering::Equal)
});
if round + 1 < params.rounds {
let cut = order[(d_out + 1).min(k - 1)];
let ritz_cut = b[cut * k + cut];
bound = ritz_cut
.max(T::from(0.1).unwrap())
.min(T::from(0.9995).unwrap());
}
ritz_vectors = s;
ritz_order = order;
}
let top = &ritz_order[..d_out];
let mut out = vec![zero; n * d_out];
out.par_chunks_mut(d_out)
.enumerate()
.for_each(|(i, out_row)| {
let v_row = &v[i * k..(i + 1) * k];
for (d, &col) in top.iter().enumerate() {
let mut acc = zero;
for p in 0..k {
acc += v_row[p] * ritz_vectors[p * k + col];
}
out_row[d] = acc;
}
});
out
}
fn chunked_column_reduce<T, F>(n: usize, width: usize, accumulate: F) -> Vec<T>
where
T: Float + AddAssign + Send + Sync,
F: Fn(Range<usize>, &mut [T]) + Send + Sync,
{
let chunks = n.div_ceil(REDUCTION_ROWS).max(1);
let partials: Vec<Vec<T>> = (0..chunks)
.into_par_iter()
.map(|chunk| {
let start = chunk * REDUCTION_ROWS;
let end = ((chunk + 1) * REDUCTION_ROWS).min(n);
let mut acc = vec![T::zero(); width];
accumulate(start..end, &mut acc);
acc
})
.collect();
let mut total = vec![T::zero(); width];
for partial in partials {
for (t, p) in total.iter_mut().zip(partial) {
*t += p;
}
}
total
}
fn column_norm<T>(v: &[T], n: usize, k: usize, c: usize) -> T
where
T: Float + AddAssign + Send + Sync,
{
chunked_column_reduce(n, 1, |range, acc: &mut [T]| {
for i in range {
let val = v[i * k + c];
acc[0] += val * val;
}
})[0]
.sqrt()
}
#[inline]
fn splitmix_unit<T: Float>(index: u64) -> T {
let mut z = index.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
let unit = (z >> 11) as f64 / (1u64 << 53) as f64;
T::from(unit - 0.5).unwrap()
}
#[allow(clippy::too_many_arguments)]
fn matvec_combine<T, S>(
v: &[T],
v_prev: &[T],
out: &mut [T],
inv_sqrt_d: &[T],
p_rows: &[usize],
edge_cols: &[u32],
edge_weights: &[T],
mul_mv: T,
mul_v: T,
mul_prev: T,
) where
T: Float + Default + AddAssign + Send + Sync,
S: SpectralBlock,
{
let half = T::from(0.5).unwrap();
let v_rows = S::as_rows(v);
let prev_rows = S::as_rows(v_prev);
let out_rows = S::as_rows_mut(out);
out_rows
.par_iter_mut()
.enumerate()
.for_each(|(i, out_row)| {
let mut acc = <S::Row<T>>::default();
let acc = acc.as_mut();
for e in p_rows[i]..p_rows[i + 1] {
let j = edge_cols[e] as usize;
let w = edge_weights[e];
let v_row = v_rows[j].as_ref();
for d in 0..S::WIDTH {
acc[d] += w * v_row[d];
}
}
let scale = inv_sqrt_d[i];
let v_row = v_rows[i].as_ref();
let prev_row = prev_rows[i].as_ref();
let out_row = out_row.as_mut();
for d in 0..S::WIDTH {
let mv = half * (v_row[d] + scale * acc[d]);
out_row[d] = mul_mv * mv + mul_v * v_row[d] + mul_prev * prev_row[d];
}
});
}
fn orthonormalize_block<T>(v: &mut [T], n: usize, k: usize, v0: &[T], refresh_seed: u64)
where
T: Float + AddAssign + SubAssign + DivAssign + Send + Sync,
{
let collapse = T::epsilon().sqrt();
for c in 0..k {
let mut refreshed = false;
loop {
let norm_pre = column_norm(v, n, k, c);
for _pass in 0..2 {
let dots = chunked_column_reduce(n, c + 1, |range, acc: &mut [T]| {
for i in range {
let row = &v[i * k..(i + 1) * k];
for p in 0..c {
acc[p] += row[p] * row[c];
}
acc[c] += v0[i] * row[c];
}
});
v.par_chunks_mut(k).enumerate().for_each(|(i, row)| {
let mut delta = dots[c] * v0[i];
for p in 0..c {
delta += dots[p] * row[p];
}
row[c] -= delta;
});
}
let norm = column_norm(v, n, k, c);
if norm > norm_pre * collapse {
v.par_chunks_mut(k).for_each(|row| row[c] /= norm);
break;
}
if refreshed {
v.par_chunks_mut(k).for_each(|row| row[c] = T::zero());
break;
}
refreshed = true;
v.par_chunks_mut(k).enumerate().for_each(|(i, row)| {
row[c] = splitmix_unit(refresh_seed.wrapping_add((c * n + i) as u64));
});
}
}
}
fn jacobi_eigen<T>(b: &mut [T], k: usize) -> Vec<T>
where
T: Float + Sum,
{
let zero = T::zero();
let one = T::one();
let two = one + one;
let mut s = vec![zero; k * k];
for p in 0..k {
s[p * k + p] = one;
}
if k < 2 {
return s;
}
let eps_k = T::epsilon() * T::from(k as f64).unwrap();
for _sweep in 0..50 {
let diag_sq: T = (0..k).map(|p| b[p * k + p] * b[p * k + p]).sum();
let off: T = (0..k)
.flat_map(|p| ((p + 1)..k).map(move |q| (p, q)))
.map(|(p, q)| b[p * k + q] * b[p * k + q])
.sum();
if off <= eps_k * eps_k * diag_sq {
break;
}
for p in 0..k {
for q in (p + 1)..k {
let apq = b[p * k + q];
if apq == zero {
continue;
}
let theta = (b[q * k + q] - b[p * k + p]) / (two * apq);
let t = if theta >= zero {
one / (theta + (theta * theta + one).sqrt())
} else {
one / (theta - (theta * theta + one).sqrt())
};
let cos = one / (t * t + one).sqrt();
let sin = t * cos;
for r in 0..k {
let brp = b[r * k + p];
let brq = b[r * k + q];
b[r * k + p] = cos * brp - sin * brq;
b[r * k + q] = sin * brp + cos * brq;
}
for col in 0..k {
let bpc = b[p * k + col];
let bqc = b[q * k + col];
b[p * k + col] = cos * bpc - sin * bqc;
b[q * k + col] = sin * bpc + cos * bqc;
}
for r in 0..k {
let srp = s[r * k + p];
let srq = s[r * k + q];
s[r * k + p] = cos * srp - sin * srq;
s[r * k + q] = sin * srp + cos * srq;
}
}
}
}
s
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
proptest::proptest! {
#[test]
fn splitmix_unit_stays_in_range(index in any::<u64>()) {
let value: f64 = splitmix_unit(index);
prop_assert!((-0.5..0.5).contains(&value));
}
#[test]
fn chunked_reduce_matches_serial_sum(
(width, values) in (1usize..=4).prop_flat_map(|width| {
(
Just(width),
proptest::collection::vec(-1.0f64..1.0, width..9000),
)
}),
) {
let n = values.len() / width;
let totals = chunked_column_reduce(n, width, |range, acc: &mut [f64]| {
for i in range {
for w in 0..width {
acc[w] += values[i * width + w];
}
}
});
for w in 0..width {
let serial: f64 = (0..n).map(|i| values[i * width + w]).sum();
prop_assert!(
(totals[w] - serial).abs() <= 1e-9 * (1.0 + serial.abs()),
"column {w}: chunked {} vs serial {serial}",
totals[w]
);
}
}
#[test]
fn jacobi_diagonalizes_symmetric_matrices(
(k, entries) in (1usize..=10).prop_flat_map(|k| {
(Just(k), proptest::collection::vec(-1.0f64..1.0, k * k))
}),
) {
let mut a = vec![0.0f64; k * k];
for p in 0..k {
for q in 0..k {
a[p * k + q] = (entries[p * k + q] + entries[q * k + p]) / 2.0;
}
}
let mut b = a.clone();
let s = jacobi_eigen(&mut b, k);
for p in 0..k {
for q in 0..k {
let dot: f64 = (0..k).map(|r| s[r * k + p] * s[r * k + q]).sum();
let expected = if p == q { 1.0 } else { 0.0 };
prop_assert!(
(dot - expected).abs() < 1e-9,
"s^T s deviates at ({p}, {q}): {dot}"
);
}
}
for p in 0..k {
for q in 0..k {
let lhs: f64 = (0..k).map(|r| a[p * k + r] * s[r * k + q]).sum();
let rhs = s[p * k + q] * b[q * k + q];
prop_assert!(
(lhs - rhs).abs() < 1e-8,
"a s != s diag at ({p}, {q}): {lhs} vs {rhs}"
);
}
}
}
#[test]
fn orthonormalize_yields_orthonormal_or_zero_columns(
(n, k, entries, v0_raw, collapse, seed) in (1usize..=30, 1usize..=6)
.prop_flat_map(|(n, k)| {
(
Just(n),
Just(k),
proptest::collection::vec(-1.0f64..1.0, n * k),
proptest::collection::vec(-1.0f64..1.0, n),
any::<bool>(),
any::<u64>(),
)
}),
) {
let mut v = entries;
if collapse {
for i in 0..n {
for c in 1..k {
v[i * k + c] = v[i * k] * (c as f64 + 0.5);
}
}
}
let norm0: f64 = v0_raw.iter().map(|x| x * x).sum::<f64>().sqrt();
let v0: Vec<f64> = if norm0 > 0.0 {
v0_raw.iter().map(|x| x / norm0).collect()
} else {
v0_raw
};
orthonormalize_block(&mut v, n, k, &v0, seed);
for c in 0..k {
let norm: f64 = (0..n).map(|i| v[i * k + c] * v[i * k + c]).sum::<f64>().sqrt();
prop_assert!(
norm == 0.0 || (norm - 1.0).abs() < 1e-9,
"column {c} has norm {norm}, expected 1 or exactly 0"
);
if norm > 0.5 {
let against_v0: f64 = (0..n).map(|i| v[i * k + c] * v0[i]).sum();
prop_assert!(against_v0.abs() < 1e-8, "column {c} not deflated: {against_v0}");
for p in 0..c {
let dot: f64 = (0..n).map(|i| v[i * k + p] * v[i * k + c]).sum();
prop_assert!(dot.abs() < 1e-8, "columns {p} and {c} not orthogonal: {dot}");
}
}
}
}
}
}