use rayon::prelude::*;
use scirs2_core::ndarray_ext::{Array1, Array2, ArrayView2};
use sklears_core::error::{Result as SklResult, SklearsError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ParallelStrategy {
#[default]
Auto,
Sequential,
Parallel {
min_chunk_size: usize,
},
Adaptive,
}
impl ParallelStrategy {
pub fn should_parallelize(&self, n_samples: usize) -> bool {
match self {
Self::Auto => {
let n_cpus = rayon::current_num_threads();
n_samples > 100 && n_cpus > 1
}
Self::Sequential => false,
Self::Parallel { .. } => true,
Self::Adaptive => n_samples > 50,
}
}
pub fn chunk_size(&self, n_samples: usize) -> usize {
match self {
Self::Auto => (n_samples / rayon::current_num_threads()).max(10),
Self::Sequential => n_samples,
Self::Parallel { min_chunk_size } => (*min_chunk_size).max(1),
Self::Adaptive => (n_samples / (rayon::current_num_threads() * 4)).max(5),
}
}
}
#[allow(non_snake_case)]
pub fn parallel_knn_graph(
X: &ArrayView2<f64>,
n_neighbors: usize,
strategy: ParallelStrategy,
) -> SklResult<Array2<f64>> {
let (n_samples, _n_features) = X.dim();
if n_neighbors >= n_samples {
return Err(SklearsError::InvalidInput(format!(
"n_neighbors ({}) must be less than n_samples ({})",
n_neighbors, n_samples
)));
}
let use_parallel = strategy.should_parallelize(n_samples);
if use_parallel {
parallel_knn_graph_impl(X, n_neighbors, strategy)
} else {
sequential_knn_graph_impl(X, n_neighbors)
}
}
#[allow(non_snake_case)]
fn parallel_knn_graph_impl(
X: &ArrayView2<f64>,
n_neighbors: usize,
strategy: ParallelStrategy,
) -> SklResult<Array2<f64>> {
let (n_samples, _n_features) = X.dim();
let chunk_size = strategy.chunk_size(n_samples);
let adjacency_rows: Vec<Vec<f64>> = (0..n_samples)
.into_par_iter()
.with_min_len(chunk_size)
.map(|i| {
let mut distances: Vec<(usize, f64)> = Vec::with_capacity(n_samples - 1);
for j in 0..n_samples {
if i != j {
let diff = &X.row(i) - &X.row(j);
let dist = diff.mapv(|x| x * x).sum().sqrt();
distances.push((j, dist));
}
}
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut row = vec![0.0; n_samples];
for &(j, dist) in distances.iter().take(n_neighbors) {
let weight = (-dist.powi(2) / 2.0).exp();
row[j] = weight;
}
row
})
.collect();
let mut adjacency = Array2::<f64>::zeros((n_samples, n_samples));
for (i, row) in adjacency_rows.into_iter().enumerate() {
for (j, &val) in row.iter().enumerate() {
adjacency[[i, j]] = val;
}
}
Ok(adjacency)
}
#[allow(non_snake_case)]
fn sequential_knn_graph_impl(X: &ArrayView2<f64>, n_neighbors: usize) -> SklResult<Array2<f64>> {
let (n_samples, _n_features) = X.dim();
let mut adjacency = Array2::<f64>::zeros((n_samples, n_samples));
for i in 0..n_samples {
let mut distances: Vec<(usize, f64)> = Vec::with_capacity(n_samples - 1);
for j in 0..n_samples {
if i != j {
let diff = &X.row(i) - &X.row(j);
let dist = diff.mapv(|x| x * x).sum().sqrt();
distances.push((j, dist));
}
}
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
for &(j, dist) in distances.iter().take(n_neighbors) {
let weight = (-dist.powi(2) / 2.0).exp();
adjacency[[i, j]] = weight;
}
}
Ok(adjacency)
}
pub fn parallel_graph_laplacian(
adjacency: &ArrayView2<f64>,
normalized: bool,
strategy: ParallelStrategy,
) -> SklResult<Array2<f64>> {
let (n_rows, n_cols) = adjacency.dim();
if n_rows != n_cols {
return Err(SklearsError::InvalidInput(format!(
"Adjacency matrix must be square, got shape ({}, {})",
n_rows, n_cols
)));
}
let n_samples = n_rows;
let use_parallel = strategy.should_parallelize(n_samples);
let degrees: Vec<f64> = if use_parallel {
let chunk_size = strategy.chunk_size(n_samples);
(0..n_samples)
.into_par_iter()
.with_min_len(chunk_size)
.map(|i| adjacency.row(i).sum())
.collect()
} else {
(0..n_samples).map(|i| adjacency.row(i).sum()).collect()
};
let degrees_array = Array1::from(degrees);
let mut laplacian = Array2::<f64>::zeros((n_samples, n_samples));
if normalized {
let d_inv_sqrt: Vec<f64> = degrees_array
.iter()
.map(|&d| if d > 1e-10 { 1.0 / d.sqrt() } else { 0.0 })
.collect();
if use_parallel {
let chunk_size = strategy.chunk_size(n_samples);
let rows: Vec<Vec<f64>> = (0..n_samples)
.into_par_iter()
.with_min_len(chunk_size)
.map(|i| {
let mut row = vec![0.0; n_samples];
for j in 0..n_samples {
if i == j {
row[j] = 1.0;
} else {
row[j] = -d_inv_sqrt[i] * adjacency[[i, j]] * d_inv_sqrt[j];
}
}
row
})
.collect();
for (i, row) in rows.into_iter().enumerate() {
for (j, val) in row.into_iter().enumerate() {
laplacian[[i, j]] = val;
}
}
} else {
for i in 0..n_samples {
for j in 0..n_samples {
if i == j {
laplacian[[i, j]] = 1.0;
} else {
laplacian[[i, j]] = -d_inv_sqrt[i] * adjacency[[i, j]] * d_inv_sqrt[j];
}
}
}
}
} else {
for i in 0..n_samples {
for j in 0..n_samples {
if i == j {
laplacian[[i, j]] = degrees_array[i];
} else {
laplacian[[i, j]] = -adjacency[[i, j]];
}
}
}
}
Ok(laplacian)
}
pub fn parallel_label_propagation_step(
adjacency: &ArrayView2<f64>,
labels_current: &ArrayView2<f64>,
labels_init: &ArrayView2<f64>,
alpha: f64,
strategy: ParallelStrategy,
) -> SklResult<Array2<f64>> {
let (n_samples, n_classes) = labels_current.dim();
if adjacency.dim() != (n_samples, n_samples) {
return Err(SklearsError::InvalidInput(
"Adjacency matrix dimension mismatch".to_string(),
));
}
if labels_init.dim() != (n_samples, n_classes) {
return Err(SklearsError::InvalidInput(
"Initial labels dimension mismatch".to_string(),
));
}
let use_parallel = strategy.should_parallelize(n_samples);
if use_parallel {
let chunk_size = strategy.chunk_size(n_samples);
let rows: Vec<Vec<f64>> = (0..n_samples)
.into_par_iter()
.with_min_len(chunk_size)
.map(|i| {
let mut row = vec![0.0; n_classes];
for j in 0..n_samples {
let weight = adjacency[[i, j]];
for k in 0..n_classes {
row[k] += alpha * weight * labels_current[[j, k]];
}
}
for k in 0..n_classes {
row[k] += (1.0 - alpha) * labels_init[[i, k]];
}
row
})
.collect();
let mut labels_new = Array2::<f64>::zeros((n_samples, n_classes));
for (i, row) in rows.into_iter().enumerate() {
for (k, val) in row.into_iter().enumerate() {
labels_new[[i, k]] = val;
}
}
Ok(labels_new)
} else {
let mut labels_new = Array2::<f64>::zeros((n_samples, n_classes));
for i in 0..n_samples {
for k in 0..n_classes {
let mut propagated = 0.0;
for j in 0..n_samples {
propagated += adjacency[[i, j]] * labels_current[[j, k]];
}
labels_new[[i, k]] = alpha * propagated + (1.0 - alpha) * labels_init[[i, k]];
}
}
Ok(labels_new)
}
}
#[allow(non_snake_case)]
pub fn parallel_pairwise_distances(
X: &ArrayView2<f64>,
strategy: ParallelStrategy,
) -> SklResult<Array2<f64>> {
let (n_samples, _n_features) = X.dim();
let use_parallel = strategy.should_parallelize(n_samples);
if use_parallel {
let chunk_size = strategy.chunk_size(n_samples);
let rows: Vec<Vec<f64>> = (0..n_samples)
.into_par_iter()
.with_min_len(chunk_size)
.map(|i| {
let mut row = vec![0.0; n_samples];
let xi = X.row(i);
#[allow(clippy::needless_range_loop)]
for j in 0..n_samples {
if i == j {
row[j] = 0.0;
} else {
let diff = &xi - &X.row(j);
let dist = diff.mapv(|x| x * x).sum().sqrt();
row[j] = dist;
}
}
row
})
.collect();
let mut distances = Array2::<f64>::zeros((n_samples, n_samples));
for (i, row) in rows.into_iter().enumerate() {
for (j, val) in row.into_iter().enumerate() {
distances[[i, j]] = val;
}
}
Ok(distances)
} else {
let mut distances = Array2::<f64>::zeros((n_samples, n_samples));
for i in 0..n_samples {
for j in (i + 1)..n_samples {
let diff = &X.row(i) - &X.row(j);
let dist = diff.mapv(|x| x * x).sum().sqrt();
distances[[i, j]] = dist;
distances[[j, i]] = dist;
}
}
Ok(distances)
}
}
#[derive(Debug, Clone)]
pub struct ParallelStats {
pub n_threads: usize,
pub n_samples: usize,
pub chunk_size: usize,
pub used_parallel: bool,
}
impl ParallelStats {
pub fn current(n_samples: usize, strategy: ParallelStrategy) -> Self {
let used_parallel = strategy.should_parallelize(n_samples);
Self {
n_threads: rayon::current_num_threads(),
n_samples,
chunk_size: strategy.chunk_size(n_samples),
used_parallel,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::array;
#[test]
fn test_parallel_strategy() {
let strategy = ParallelStrategy::Auto;
assert!(!strategy.should_parallelize(50));
assert!(strategy.should_parallelize(200));
let strategy = ParallelStrategy::Sequential;
assert!(!strategy.should_parallelize(1000));
let strategy = ParallelStrategy::Parallel { min_chunk_size: 10 };
assert!(strategy.should_parallelize(100));
assert_eq!(strategy.chunk_size(100), 10);
}
#[test]
#[allow(non_snake_case)]
fn test_parallel_knn_graph_small() {
let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0], [4.0, 5.0]];
let graph = parallel_knn_graph(&X.view(), 2, ParallelStrategy::Auto)
.expect("operation should succeed");
assert_eq!(graph.dim(), (4, 4));
for i in 0..4 {
assert_eq!(graph[[i, i]], 0.0);
}
for i in 0..4 {
let non_zero = graph.row(i).iter().filter(|&&x| x > 0.0).count();
assert_eq!(non_zero, 2);
}
}
#[test]
#[allow(non_snake_case)]
fn test_parallel_knn_graph_forced_parallel() {
let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0], [4.0, 5.0], [5.0, 6.0]];
let graph = parallel_knn_graph(
&X.view(),
2,
ParallelStrategy::Parallel { min_chunk_size: 1 },
)
.expect("operation should succeed");
assert_eq!(graph.dim(), (5, 5));
for i in 0..5 {
let non_zero = graph.row(i).iter().filter(|&&x| x > 0.0).count();
assert_eq!(non_zero, 2);
}
}
#[test]
fn test_parallel_graph_laplacian() {
let adj = array![[0.0, 1.0, 0.5], [1.0, 0.0, 0.8], [0.5, 0.8, 0.0]];
let laplacian = parallel_graph_laplacian(&adj.view(), true, ParallelStrategy::Auto)
.expect("operation should succeed");
assert_eq!(laplacian.dim(), (3, 3));
for i in 0..3 {
assert!((laplacian[[i, i]] - 1.0).abs() < 1e-10);
}
}
#[test]
fn test_parallel_graph_laplacian_unnormalized() {
let adj = array![[0.0, 1.0, 0.5], [1.0, 0.0, 0.8], [0.5, 0.8, 0.0]];
let laplacian = parallel_graph_laplacian(&adj.view(), false, ParallelStrategy::Auto)
.expect("operation should succeed");
assert_eq!(laplacian.dim(), (3, 3));
for i in 0..3 {
let row_sum: f64 = adj.row(i).sum();
assert!((laplacian[[i, i]] - row_sum).abs() < 1e-10);
}
}
#[test]
fn test_parallel_label_propagation_step() {
let adj = array![[0.0, 0.5, 0.5], [0.5, 0.0, 0.5], [0.5, 0.5, 0.0]];
let labels_current = array![[1.0, 0.0], [0.0, 1.0], [0.5, 0.5]];
let labels_init = array![[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]];
let labels_new = parallel_label_propagation_step(
&adj.view(),
&labels_current.view(),
&labels_init.view(),
0.5,
ParallelStrategy::Auto,
)
.expect("operation should succeed");
assert_eq!(labels_new.dim(), (3, 2));
assert!(labels_new[[0, 0]] > 0.4); assert!(labels_new[[1, 1]] > 0.4);
for i in 0..3 {
let row_sum: f64 = (0..2).map(|k| labels_new[[i, k]]).sum();
assert!(row_sum > 0.0); }
}
#[test]
#[allow(non_snake_case)]
fn test_parallel_pairwise_distances() {
let X = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
let distances = parallel_pairwise_distances(&X.view(), ParallelStrategy::Auto)
.expect("operation should succeed");
assert_eq!(distances.dim(), (4, 4));
for i in 0..4 {
assert_eq!(distances[[i, i]], 0.0);
}
for i in 0..4 {
for j in 0..4 {
assert!((distances[[i, j]] - distances[[j, i]]).abs() < 1e-10);
}
}
assert!((distances[[0, 1]] - 1.0).abs() < 1e-10); assert!((distances[[0, 2]] - 1.0).abs() < 1e-10); assert!((distances[[0, 3]] - 2.0_f64.sqrt()).abs() < 1e-10); }
#[test]
fn test_parallel_stats() {
let stats = ParallelStats::current(100, ParallelStrategy::Auto);
assert_eq!(stats.n_samples, 100);
assert!(!stats.used_parallel);
let stats = ParallelStats::current(200, ParallelStrategy::Auto);
assert!(stats.used_parallel); }
#[test]
#[allow(non_snake_case)]
fn test_knn_graph_error_handling() {
let X = array![[1.0, 2.0], [2.0, 3.0]];
let result = parallel_knn_graph(&X.view(), 5, ParallelStrategy::Auto);
assert!(result.is_err()); }
#[test]
fn test_laplacian_error_handling() {
let adj = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]; let result = parallel_graph_laplacian(&adj.view(), true, ParallelStrategy::Auto);
assert!(result.is_err());
}
}