Skip to main content

scirs2_cluster/
subspace.rs

1//! Subspace clustering algorithms
2//!
3//! This module provides implementations of clustering algorithms that operate in
4//! subspaces of the full feature space, enabling discovery of clusters that exist
5//! only in projections of the data.
6//!
7//! # Algorithms
8//!
9//! - **CLIQUE** (CLustering In QUEst): Grid-based algorithm for axis-aligned subspaces
10//! - **PROCLUS** (PROjected CLUStering): K-medoid based projected clustering
11//! - **SSC** (Sparse Subspace Clustering): Self-expression based subspace clustering
12//! - **Feature subspace selection**: Methods for finding relevant feature subsets
13//! - **Subspace quality metrics**: Evaluate clustering quality in subspaces
14
15use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1, ArrayView2, Axis};
16use scirs2_core::numeric::{Float, FromPrimitive};
17use std::collections::{BTreeSet, HashMap, HashSet};
18use std::fmt::Debug;
19
20use crate::error::{ClusteringError, Result};
21
22// ---------------------------------------------------------------------------
23// CLIQUE algorithm
24// ---------------------------------------------------------------------------
25
26/// Configuration for the CLIQUE algorithm.
27#[derive(Debug, Clone)]
28pub struct CliqueConfig {
29    /// Number of equal-width intervals per dimension (xi).
30    pub n_intervals: usize,
31    /// Minimum fraction of total data points for a cell to be dense (tau).
32    pub density_threshold: f64,
33    /// Maximum subspace dimensionality to explore (0 = unlimited).
34    pub max_subspace_dim: usize,
35}
36
37impl Default for CliqueConfig {
38    fn default() -> Self {
39        Self {
40            n_intervals: 10,
41            density_threshold: 0.1,
42            max_subspace_dim: 0,
43        }
44    }
45}
46
47/// A dense cell in the CLIQUE grid.
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub struct DenseCell {
50    /// Feature indices that define the subspace.
51    pub dimensions: Vec<usize>,
52    /// Grid coordinate in each dimension.
53    pub coords: Vec<usize>,
54}
55
56/// Result of the CLIQUE algorithm.
57#[derive(Debug, Clone)]
58pub struct CliqueResult {
59    /// Cluster labels per data point (-1 = not assigned).
60    pub labels: Array1<i32>,
61    /// Dense subspaces discovered (dimension sets with dense cells).
62    pub subspaces: Vec<Vec<usize>>,
63    /// Dense cells found (grouped by subspace dimensionality).
64    pub dense_cells: Vec<DenseCell>,
65    /// Number of clusters found.
66    pub n_clusters: usize,
67}
68
69/// Run the CLIQUE algorithm for axis-aligned subspace clustering.
70///
71/// CLIQUE partitions each dimension into equal-width intervals, identifies
72/// dense cells, then joins them bottom-up to discover higher-dimensional
73/// dense subspaces. Connected dense cells in each subspace form clusters.
74pub fn clique<F: Float + FromPrimitive + Debug>(
75    data: ArrayView2<F>,
76    config: &CliqueConfig,
77) -> Result<CliqueResult> {
78    let (n_samples, n_features) = (data.shape()[0], data.shape()[1]);
79
80    if n_samples == 0 {
81        return Err(ClusteringError::InvalidInput("Empty input data".into()));
82    }
83    if config.n_intervals == 0 {
84        return Err(ClusteringError::InvalidInput(
85            "n_intervals must be > 0".into(),
86        ));
87    }
88    if config.density_threshold <= 0.0 || config.density_threshold > 1.0 {
89        return Err(ClusteringError::InvalidInput(
90            "density_threshold must be in (0, 1]".into(),
91        ));
92    }
93
94    let tau_count = (config.density_threshold * n_samples as f64).ceil() as usize;
95
96    // Compute per-dimension min/max for grid construction
97    let mut mins = vec![F::infinity(); n_features];
98    let mut maxs = vec![F::neg_infinity(); n_features];
99    for i in 0..n_samples {
100        for d in 0..n_features {
101            let v = data[[i, d]];
102            if v < mins[d] {
103                mins[d] = v;
104            }
105            if v > maxs[d] {
106                maxs[d] = v;
107            }
108        }
109    }
110
111    let xi = config.n_intervals;
112    let eps = F::from(1e-10).unwrap_or_else(|| F::epsilon());
113
114    // Map a value in dimension d to a grid index in [0, xi)
115    let grid_index = |val: F, d: usize| -> usize {
116        let range = maxs[d] - mins[d] + eps;
117        let idx_f = ((val - mins[d]) / range) * F::from(xi).unwrap_or_else(|| F::one());
118        let idx = idx_f.to_usize().unwrap_or(0).min(xi - 1);
119        idx
120    };
121
122    // --- Step 1: find 1-D dense cells ---
123    let mut one_d_dense: HashMap<usize, HashSet<usize>> = HashMap::new(); // dim -> set of grid coords
124    for d in 0..n_features {
125        let mut counts = vec![0usize; xi];
126        for i in 0..n_samples {
127            let idx = grid_index(data[[i, d]], d);
128            counts[idx] += 1;
129        }
130        let dense_coords: HashSet<usize> = counts
131            .iter()
132            .enumerate()
133            .filter(|(_, &c)| c >= tau_count)
134            .map(|(idx, _)| idx)
135            .collect();
136        if !dense_coords.is_empty() {
137            one_d_dense.insert(d, dense_coords);
138        }
139    }
140
141    // Collect all dense cells across all dimensionalities
142    let mut all_dense_cells: Vec<DenseCell> = Vec::new();
143    for (&d, coords) in &one_d_dense {
144        for &c in coords {
145            all_dense_cells.push(DenseCell {
146                dimensions: vec![d],
147                coords: vec![c],
148            });
149        }
150    }
151
152    // --- Step 2: bottom-up join to find higher-dimensional dense subspaces ---
153    // Current level dense cells keyed by (sorted dims, coords)
154    let mut current_level: HashSet<(Vec<usize>, Vec<usize>)> = all_dense_cells
155        .iter()
156        .map(|c| (c.dimensions.clone(), c.coords.clone()))
157        .collect();
158
159    let max_dim = if config.max_subspace_dim == 0 {
160        n_features
161    } else {
162        config.max_subspace_dim.min(n_features)
163    };
164
165    // Precompute per-sample grid indices
166    let mut sample_grid: Vec<Vec<usize>> = Vec::with_capacity(n_samples);
167    for i in 0..n_samples {
168        let mut row = Vec::with_capacity(n_features);
169        for d in 0..n_features {
170            row.push(grid_index(data[[i, d]], d));
171        }
172        sample_grid.push(row);
173    }
174
175    let mut dim_level = 1usize;
176
177    while dim_level < max_dim && !current_level.is_empty() {
178        let mut candidate_set: HashSet<(Vec<usize>, Vec<usize>)> = HashSet::new();
179
180        let cells_vec: Vec<(Vec<usize>, Vec<usize>)> = current_level.iter().cloned().collect();
181
182        // Apriori-style candidate generation: merge cells sharing (k-1) common dims
183        for i in 0..cells_vec.len() {
184            for j in (i + 1)..cells_vec.len() {
185                let (ref dims_a, ref coords_a) = cells_vec[i];
186                let (ref dims_b, ref coords_b) = cells_vec[j];
187
188                if dims_a.len() != dim_level || dims_b.len() != dim_level {
189                    continue;
190                }
191
192                // Check if they share (k-1) common dimensions
193                let mut merged_dims: BTreeSet<usize> = BTreeSet::new();
194                for &d in dims_a {
195                    merged_dims.insert(d);
196                }
197                for &d in dims_b {
198                    merged_dims.insert(d);
199                }
200
201                if merged_dims.len() != dim_level + 1 {
202                    continue;
203                }
204
205                let merged_dims_vec: Vec<usize> = merged_dims.iter().copied().collect();
206
207                // Build merged coord vector
208                let mut merged_coords: Vec<usize> = Vec::with_capacity(dim_level + 1);
209                for &md in &merged_dims_vec {
210                    // Find coordinate for this dimension
211                    if let Some(pos) = dims_a.iter().position(|&x| x == md) {
212                        merged_coords.push(coords_a[pos]);
213                    } else if let Some(pos) = dims_b.iter().position(|&x| x == md) {
214                        merged_coords.push(coords_b[pos]);
215                    }
216                }
217
218                if merged_coords.len() != dim_level + 1 {
219                    continue;
220                }
221
222                candidate_set.insert((merged_dims_vec, merged_coords));
223            }
224        }
225
226        // Filter candidates: keep only those that are actually dense
227        let mut next_level: HashSet<(Vec<usize>, Vec<usize>)> = HashSet::new();
228
229        for (dims, coords) in &candidate_set {
230            let mut count = 0usize;
231            for sg in &sample_grid {
232                let mut matches = true;
233                for (pos, &d) in dims.iter().enumerate() {
234                    if sg[d] != coords[pos] {
235                        matches = false;
236                        break;
237                    }
238                }
239                if matches {
240                    count += 1;
241                }
242            }
243
244            if count >= tau_count {
245                all_dense_cells.push(DenseCell {
246                    dimensions: dims.clone(),
247                    coords: coords.clone(),
248                });
249                next_level.insert((dims.clone(), coords.clone()));
250            }
251        }
252
253        current_level = next_level;
254        dim_level += 1;
255    }
256
257    // --- Step 3: assign labels via connected-component analysis on dense cells ---
258    // Use highest-dimensional dense cells for labeling
259    let max_found_dim = all_dense_cells
260        .iter()
261        .map(|c| c.dimensions.len())
262        .max()
263        .unwrap_or(0);
264
265    let top_cells: Vec<&DenseCell> = all_dense_cells
266        .iter()
267        .filter(|c| c.dimensions.len() == max_found_dim)
268        .collect();
269
270    // Build adjacency (two cells are adjacent if they differ by 1 in exactly one coord)
271    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); top_cells.len()];
272    for i in 0..top_cells.len() {
273        for j in (i + 1)..top_cells.len() {
274            if top_cells[i].dimensions == top_cells[j].dimensions {
275                let mut diff_count = 0usize;
276                for k in 0..top_cells[i].coords.len() {
277                    let a = top_cells[i].coords[k] as i64;
278                    let b = top_cells[j].coords[k] as i64;
279                    if (a - b).unsigned_abs() == 1 {
280                        diff_count += 1;
281                    } else if a != b {
282                        diff_count = 2; // not adjacent
283                        break;
284                    }
285                }
286                if diff_count == 1 {
287                    adj[i].push(j);
288                    adj[j].push(i);
289                }
290            }
291        }
292    }
293
294    // Connected components via BFS
295    let mut cell_labels = vec![-1i32; top_cells.len()];
296    let mut cluster_id = 0i32;
297    for start in 0..top_cells.len() {
298        if cell_labels[start] >= 0 {
299            continue;
300        }
301        let mut queue = vec![start];
302        cell_labels[start] = cluster_id;
303        let mut head = 0usize;
304        while head < queue.len() {
305            let cur = queue[head];
306            head += 1;
307            for &nb in &adj[cur] {
308                if cell_labels[nb] < 0 {
309                    cell_labels[nb] = cluster_id;
310                    queue.push(nb);
311                }
312            }
313        }
314        cluster_id += 1;
315    }
316
317    // Assign data points to clusters
318    let mut labels = Array1::from_elem(n_samples, -1i32);
319    for i in 0..n_samples {
320        for (ci, cell) in top_cells.iter().enumerate() {
321            let mut in_cell = true;
322            for (pos, &d) in cell.dimensions.iter().enumerate() {
323                if sample_grid[i][d] != cell.coords[pos] {
324                    in_cell = false;
325                    break;
326                }
327            }
328            if in_cell {
329                labels[i] = cell_labels[ci];
330                break;
331            }
332        }
333    }
334
335    // Collect discovered subspaces
336    let mut subspace_set: HashSet<Vec<usize>> = HashSet::new();
337    for cell in &all_dense_cells {
338        if cell.dimensions.len() > 1 {
339            subspace_set.insert(cell.dimensions.clone());
340        }
341    }
342    let subspaces: Vec<Vec<usize>> = subspace_set.into_iter().collect();
343
344    Ok(CliqueResult {
345        labels,
346        subspaces,
347        dense_cells: all_dense_cells,
348        n_clusters: cluster_id as usize,
349    })
350}
351
352// ---------------------------------------------------------------------------
353// PROCLUS algorithm
354// ---------------------------------------------------------------------------
355
356/// Configuration for the PROCLUS algorithm.
357#[derive(Debug, Clone)]
358pub struct ProclusConfig {
359    /// Number of clusters (k).
360    pub n_clusters: usize,
361    /// Average number of dimensions per cluster (l).
362    pub avg_dimensions: usize,
363    /// Number of medoid candidates = n_clusters * multiplier.
364    pub candidate_multiplier: usize,
365    /// Maximum iterations for the iterative phase.
366    pub max_iterations: usize,
367    /// Minimum improvement ratio for convergence.
368    pub min_improvement: f64,
369}
370
371impl Default for ProclusConfig {
372    fn default() -> Self {
373        Self {
374            n_clusters: 3,
375            avg_dimensions: 2,
376            candidate_multiplier: 10,
377            max_iterations: 30,
378            min_improvement: 1e-4,
379        }
380    }
381}
382
383/// Result of the PROCLUS algorithm.
384#[derive(Debug, Clone)]
385pub struct ProclusResult<F: Float> {
386    /// Cluster labels per data point (-1 = not assigned).
387    pub labels: Array1<i32>,
388    /// Medoid indices into the original data.
389    pub medoids: Vec<usize>,
390    /// Dimensions selected for each cluster (cluster_idx -> dim list).
391    pub cluster_dimensions: Vec<Vec<usize>>,
392    /// Medoid coordinates.
393    pub medoid_coords: Array2<F>,
394    /// Number of iterations run.
395    pub iterations: usize,
396}
397
398/// Run the PROCLUS projected clustering algorithm.
399///
400/// PROCLUS selects k medoids and, for each medoid, identifies a subset of
401/// dimensions (the "projected subspace") where the cluster is most compact.
402/// Points are assigned to the closest medoid in its projected subspace.
403pub fn proclus<F: Float + FromPrimitive + Debug>(
404    data: ArrayView2<F>,
405    config: &ProclusConfig,
406) -> Result<ProclusResult<F>> {
407    let (n_samples, n_features) = (data.shape()[0], data.shape()[1]);
408
409    if n_samples == 0 {
410        return Err(ClusteringError::InvalidInput("Empty input data".into()));
411    }
412    if config.n_clusters == 0 || config.n_clusters > n_samples {
413        return Err(ClusteringError::InvalidInput(
414            "n_clusters must be in [1, n_samples]".into(),
415        ));
416    }
417    if config.avg_dimensions == 0 || config.avg_dimensions > n_features {
418        return Err(ClusteringError::InvalidInput(
419            "avg_dimensions must be in [1, n_features]".into(),
420        ));
421    }
422
423    let k = config.n_clusters;
424    let l = config.avg_dimensions;
425
426    // --- Initialization phase: greedy medoid selection ---
427    let n_candidates = (k * config.candidate_multiplier).min(n_samples);
428    let mut medoids = greedy_initial_medoids(data, n_candidates, k);
429
430    // --- Iterative phase ---
431    let mut cluster_dims: Vec<Vec<usize>> = vec![Vec::new(); k];
432    let mut labels = Array1::from_elem(n_samples, -1i32);
433    let mut prev_objective = f64::MAX;
434    let mut iterations = 0usize;
435
436    for iter in 0..config.max_iterations {
437        iterations = iter + 1;
438
439        // Determine dimensions for each medoid
440        cluster_dims = find_cluster_dimensions(data, &medoids, l, n_features);
441
442        // Assign points to nearest medoid in projected subspace
443        labels = assign_projected(data, &medoids, &cluster_dims);
444
445        // Compute objective (sum of projected distances)
446        let objective = compute_projected_objective(data, &medoids, &cluster_dims, &labels);
447
448        let improvement = if prev_objective < f64::MAX {
449            (prev_objective - objective) / prev_objective.abs().max(1e-15)
450        } else {
451            1.0
452        };
453
454        if improvement < config.min_improvement && iter > 0 {
455            break;
456        }
457        prev_objective = objective;
458
459        // Update medoids: for each cluster, pick the sample minimising projected distance
460        for ci in 0..k {
461            let mut best_idx = medoids[ci];
462            let mut best_cost = f64::MAX;
463            for i in 0..n_samples {
464                if labels[i] != ci as i32 {
465                    continue;
466                }
467                let cost = cluster_projected_cost(data, i, &cluster_dims[ci], &labels, ci as i32);
468                if cost < best_cost {
469                    best_cost = cost;
470                    best_idx = i;
471                }
472            }
473            medoids[ci] = best_idx;
474        }
475    }
476
477    let mut medoid_coords = Array2::zeros((k, n_features));
478    for (ci, &mi) in medoids.iter().enumerate() {
479        medoid_coords.row_mut(ci).assign(&data.row(mi));
480    }
481
482    Ok(ProclusResult {
483        labels,
484        medoids,
485        cluster_dimensions: cluster_dims,
486        medoid_coords,
487        iterations,
488    })
489}
490
491/// Greedy initialization: pick n_candidates spread-out samples, then select k.
492fn greedy_initial_medoids<F: Float + FromPrimitive + Debug>(
493    data: ArrayView2<F>,
494    n_candidates: usize,
495    k: usize,
496) -> Vec<usize> {
497    let n = data.shape()[0];
498    let nc = n_candidates.min(n);
499
500    // Deterministic spread selection: pick every n/nc-th sample
501    let step = (n as f64 / nc as f64).max(1.0);
502    let candidates: Vec<usize> = (0..nc)
503        .map(|i| ((i as f64 * step) as usize).min(n - 1))
504        .collect();
505
506    if candidates.len() <= k {
507        return candidates;
508    }
509
510    // From candidates, greedily pick k that are spread out
511    let mut selected = Vec::with_capacity(k);
512    selected.push(candidates[0]);
513    let mut min_dists = vec![f64::MAX; candidates.len()];
514
515    for _ in 1..k {
516        let last = *selected.last().unwrap_or(&0);
517        for (ci, &c) in candidates.iter().enumerate() {
518            let d = euclidean_sq_f64(data.row(c), data.row(last));
519            if d < min_dists[ci] {
520                min_dists[ci] = d;
521            }
522        }
523        // Pick the candidate with max min-distance
524        let mut best_ci = 0;
525        let mut best_d = -1.0f64;
526        for (ci, &d) in min_dists.iter().enumerate() {
527            if d > best_d && !selected.contains(&candidates[ci]) {
528                best_d = d;
529                best_ci = ci;
530            }
531        }
532        selected.push(candidates[best_ci]);
533    }
534
535    selected
536}
537
538/// Find the best l dimensions for each medoid based on average spread.
539fn find_cluster_dimensions<F: Float + FromPrimitive + Debug>(
540    data: ArrayView2<F>,
541    medoids: &[usize],
542    avg_l: usize,
543    n_features: usize,
544) -> Vec<Vec<usize>> {
545    let n_samples = data.shape()[0];
546    let k = medoids.len();
547    let total_dims = avg_l * k;
548
549    // Assign each point to nearest medoid (full space, for dimension selection)
550    let mut assignments = vec![0usize; n_samples];
551    for i in 0..n_samples {
552        let mut best_m = 0;
553        let mut best_d = f64::MAX;
554        for (mi, &m) in medoids.iter().enumerate() {
555            let d = euclidean_sq_f64(data.row(i), data.row(m));
556            if d < best_d {
557                best_d = d;
558                best_m = mi;
559            }
560        }
561        assignments[i] = best_m;
562    }
563
564    // For each medoid and dimension, compute average absolute deviation
565    let mut spreads: Vec<Vec<f64>> = vec![vec![0.0; n_features]; k];
566    let mut counts = vec![0usize; k];
567
568    for i in 0..n_samples {
569        let ci = assignments[i];
570        counts[ci] += 1;
571        let mi = medoids[ci];
572        for d in 0..n_features {
573            let diff = (data[[i, d]] - data[[mi, d]]).abs().to_f64().unwrap_or(0.0);
574            spreads[ci][d] += diff;
575        }
576    }
577
578    for ci in 0..k {
579        if counts[ci] > 0 {
580            for d in 0..n_features {
581                spreads[ci][d] /= counts[ci] as f64;
582            }
583        }
584    }
585
586    // Collect (spread, cluster_idx, dim_idx) and sort ascending
587    let mut dim_spread: Vec<(f64, usize, usize)> = Vec::new();
588    for ci in 0..k {
589        for d in 0..n_features {
590            dim_spread.push((spreads[ci][d], ci, d));
591        }
592    }
593    dim_spread.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
594
595    // Greedily assign top-total_dims lowest-spread (cluster, dim) pairs
596    let mut cluster_dims: Vec<Vec<usize>> = vec![Vec::new(); k];
597    let mut assigned = 0usize;
598    for &(_, ci, d) in &dim_spread {
599        if assigned >= total_dims {
600            break;
601        }
602        if !cluster_dims[ci].contains(&d) {
603            cluster_dims[ci].push(d);
604            assigned += 1;
605        }
606    }
607
608    // Ensure every cluster has at least one dimension
609    for ci in 0..k {
610        if cluster_dims[ci].is_empty() {
611            cluster_dims[ci].push(0);
612        }
613        cluster_dims[ci].sort();
614    }
615
616    cluster_dims
617}
618
619/// Assign each point to the nearest medoid using projected distance.
620fn assign_projected<F: Float + FromPrimitive + Debug>(
621    data: ArrayView2<F>,
622    medoids: &[usize],
623    cluster_dims: &[Vec<usize>],
624) -> Array1<i32> {
625    let n = data.shape()[0];
626    let k = medoids.len();
627    let mut labels = Array1::from_elem(n, -1i32);
628
629    for i in 0..n {
630        let mut best_m = 0i32;
631        let mut best_d = f64::MAX;
632        for ci in 0..k {
633            let mi = medoids[ci];
634            let dims = &cluster_dims[ci];
635            let mut dist = 0.0f64;
636            for &d in dims {
637                let diff = (data[[i, d]] - data[[mi, d]]).to_f64().unwrap_or(0.0);
638                dist += diff * diff;
639            }
640            if dist < best_d {
641                best_d = dist;
642                best_m = ci as i32;
643            }
644        }
645        labels[i] = best_m;
646    }
647
648    labels
649}
650
651/// Compute the total projected objective (sum of squared projected distances).
652fn compute_projected_objective<F: Float + FromPrimitive + Debug>(
653    data: ArrayView2<F>,
654    medoids: &[usize],
655    cluster_dims: &[Vec<usize>],
656    labels: &Array1<i32>,
657) -> f64 {
658    let n = data.shape()[0];
659    let mut total = 0.0f64;
660    for i in 0..n {
661        let ci = labels[i];
662        if ci < 0 {
663            continue;
664        }
665        let ci = ci as usize;
666        let mi = medoids[ci];
667        for &d in &cluster_dims[ci] {
668            let diff = (data[[i, d]] - data[[mi, d]]).to_f64().unwrap_or(0.0);
669            total += diff * diff;
670        }
671    }
672    total
673}
674
675/// Cost of making sample `idx` a medoid for cluster `ci`.
676fn cluster_projected_cost<F: Float + FromPrimitive + Debug>(
677    data: ArrayView2<F>,
678    idx: usize,
679    dims: &[usize],
680    labels: &Array1<i32>,
681    ci_label: i32,
682) -> f64 {
683    let n = data.shape()[0];
684    let mut cost = 0.0f64;
685    for i in 0..n {
686        if labels[i] != ci_label {
687            continue;
688        }
689        for &d in dims {
690            let diff = (data[[i, d]] - data[[idx, d]]).to_f64().unwrap_or(0.0);
691            cost += diff * diff;
692        }
693    }
694    cost
695}
696
697// ---------------------------------------------------------------------------
698// Sparse Subspace Clustering (SSC)
699// ---------------------------------------------------------------------------
700
701/// Configuration for Sparse Subspace Clustering.
702#[derive(Debug, Clone)]
703pub struct SscConfig {
704    /// Regularisation parameter for L1 penalty (lambda).
705    pub lambda: f64,
706    /// Maximum ADMM iterations for the L1 self-expression solver.
707    pub max_iterations: usize,
708    /// Convergence tolerance for ADMM.
709    pub tolerance: f64,
710    /// Number of clusters (for spectral clustering on the affinity).
711    pub n_clusters: usize,
712    /// ADMM penalty parameter (rho).
713    pub rho: f64,
714}
715
716impl Default for SscConfig {
717    fn default() -> Self {
718        Self {
719            lambda: 0.1,
720            max_iterations: 200,
721            tolerance: 1e-6,
722            n_clusters: 3,
723            rho: 1.0,
724        }
725    }
726}
727
728/// Result of Sparse Subspace Clustering.
729#[derive(Debug, Clone)]
730pub struct SscResult<F: Float> {
731    /// Cluster labels for each data point.
732    pub labels: Array1<i32>,
733    /// Sparse self-expression coefficient matrix (n_samples x n_samples).
734    pub coefficients: Array2<F>,
735    /// Affinity matrix used for spectral clustering.
736    pub affinity: Array2<F>,
737    /// Number of clusters found.
738    pub n_clusters: usize,
739}
740
741/// Run Sparse Subspace Clustering via self-expression.
742///
743/// Solves: min ||C||_1 subject to X = X C, diag(C) = 0
744/// using ADMM, then builds an affinity matrix |C| + |C^T| and performs
745/// spectral clustering to obtain final labels.
746pub fn ssc<F: Float + FromPrimitive + Debug>(
747    data: ArrayView2<F>,
748    config: &SscConfig,
749) -> Result<SscResult<F>> {
750    let (n_samples, n_features) = (data.shape()[0], data.shape()[1]);
751
752    if n_samples == 0 {
753        return Err(ClusteringError::InvalidInput("Empty input data".into()));
754    }
755    if config.n_clusters == 0 || config.n_clusters > n_samples {
756        return Err(ClusteringError::InvalidInput(
757            "n_clusters must be in [1, n_samples]".into(),
758        ));
759    }
760
761    // Solve the L1-penalised self-expression problem via ADMM
762    // min lambda*||C||_1 + 0.5*||X - X*C||_F^2  s.t. diag(C)=0
763    let coefficients = ssc_admm(data, config)?;
764
765    // Build symmetric affinity: W = |C| + |C^T|
766    let mut affinity = Array2::<F>::zeros((n_samples, n_samples));
767    for i in 0..n_samples {
768        for j in 0..n_samples {
769            affinity[[i, j]] = coefficients[[i, j]].abs() + coefficients[[j, i]].abs();
770        }
771    }
772
773    // Spectral clustering on the affinity matrix
774    let labels = spectral_from_affinity(&affinity, config.n_clusters)?;
775    let n_clusters = config.n_clusters;
776
777    Ok(SscResult {
778        labels,
779        coefficients,
780        affinity,
781        n_clusters,
782    })
783}
784
785/// ADMM solver for L1-penalised self-expression.
786fn ssc_admm<F: Float + FromPrimitive + Debug>(
787    data: ArrayView2<F>,
788    config: &SscConfig,
789) -> Result<Array2<F>> {
790    let n = data.shape()[0];
791    let lambda_f = F::from(config.lambda).unwrap_or_else(|| F::one());
792    let rho_f = F::from(config.rho).unwrap_or_else(|| F::one());
793    let one = F::one();
794
795    // Compute X^T X
796    let mut xtx = Array2::<F>::zeros((n, n));
797    for i in 0..n {
798        for j in 0..n {
799            let mut dot = F::zero();
800            for d in 0..data.shape()[1] {
801                dot = dot + data[[i, d]] * data[[j, d]];
802            }
803            xtx[[i, j]] = dot;
804        }
805    }
806
807    // Precompute (X^T X + rho * I)^{-1} via simple iterative method
808    // For moderate n, use direct Cholesky-like decomposition
809    // We use a simplified approach: (X^T X + rho*I) * C_col = X^T X_col + rho * Z_col - U_col
810    // Solved per-column via conjugate gradient-like iterations
811
812    let mut c_mat = Array2::<F>::zeros((n, n));
813    let mut z_mat = Array2::<F>::zeros((n, n));
814    let mut u_mat = Array2::<F>::zeros((n, n));
815
816    // Build A = X^T X + rho * I
817    let mut a_mat = xtx.clone();
818    for i in 0..n {
819        a_mat[[i, i]] = a_mat[[i, i]] + rho_f;
820    }
821
822    let tol_f = F::from(config.tolerance).unwrap_or_else(|| F::epsilon());
823
824    for _iter in 0..config.max_iterations {
825        // C-update: solve A * c_j = xtx_j + rho*(z_j - u_j) for each column j
826        for j in 0..n {
827            let mut rhs = Array1::<F>::zeros(n);
828            for i in 0..n {
829                rhs[i] = xtx[[i, j]] + rho_f * (z_mat[[i, j]] - u_mat[[i, j]]);
830            }
831            // Solve A * x = rhs using Gauss-Seidel (few iterations for ADMM inner)
832            let mut x = Array1::<F>::zeros(n);
833            for _gs in 0..20 {
834                for i in 0..n {
835                    let mut sum = rhs[i];
836                    for k in 0..n {
837                        if k != i {
838                            sum = sum - a_mat[[i, k]] * x[k];
839                        }
840                    }
841                    if a_mat[[i, i]].abs() > F::epsilon() {
842                        x[i] = sum / a_mat[[i, i]];
843                    }
844                }
845            }
846            // Enforce diag(C) = 0
847            x[j] = F::zero();
848            for i in 0..n {
849                c_mat[[i, j]] = x[i];
850            }
851        }
852
853        // Z-update: soft thresholding
854        let thresh = lambda_f / rho_f;
855        for i in 0..n {
856            for j in 0..n {
857                let v = c_mat[[i, j]] + u_mat[[i, j]];
858                z_mat[[i, j]] = soft_threshold(v, thresh);
859            }
860        }
861
862        // U-update: dual variable
863        let mut primal_res = F::zero();
864        for i in 0..n {
865            for j in 0..n {
866                let r = c_mat[[i, j]] - z_mat[[i, j]];
867                u_mat[[i, j]] = u_mat[[i, j]] + r;
868                primal_res = primal_res + r * r;
869            }
870        }
871
872        if primal_res.sqrt() < tol_f {
873            break;
874        }
875    }
876
877    Ok(c_mat)
878}
879
880/// Soft thresholding operator: sign(x) * max(|x| - t, 0).
881fn soft_threshold<F: Float>(x: F, t: F) -> F {
882    let abs_x = x.abs();
883    if abs_x <= t {
884        F::zero()
885    } else if x > F::zero() {
886        abs_x - t
887    } else {
888        t - abs_x
889    }
890}
891
892/// Spectral clustering from a precomputed affinity matrix.
893fn spectral_from_affinity<F: Float + FromPrimitive + Debug>(
894    affinity: &Array2<F>,
895    k: usize,
896) -> Result<Array1<i32>> {
897    let n = affinity.shape()[0];
898    if n <= k {
899        return Ok(Array1::from_vec((0..n as i32).collect()));
900    }
901
902    // Compute degree matrix and normalised Laplacian
903    let mut degree = Array1::<F>::zeros(n);
904    for i in 0..n {
905        for j in 0..n {
906            degree[i] = degree[i] + affinity[[i, j]];
907        }
908    }
909
910    // L_sym = I - D^{-1/2} W D^{-1/2}  (Ng-Jordan-Weiss)
911    let mut l_sym = Array2::<F>::zeros((n, n));
912    for i in 0..n {
913        for j in 0..n {
914            let di = if degree[i] > F::epsilon() {
915                F::one() / degree[i].sqrt()
916            } else {
917                F::zero()
918            };
919            let dj = if degree[j] > F::epsilon() {
920                F::one() / degree[j].sqrt()
921            } else {
922                F::zero()
923            };
924            l_sym[[i, j]] = if i == j {
925                F::one() - di * affinity[[i, j]] * dj
926            } else {
927                F::zero() - di * affinity[[i, j]] * dj
928            };
929        }
930    }
931
932    // Extract k smallest eigenvectors via power iteration on (I - L_sym)
933    // which has largest eigenvalues corresponding to smallest eigenvalues of L_sym
934    let shifted = {
935        let mut m = Array2::<F>::zeros((n, n));
936        for i in 0..n {
937            for j in 0..n {
938                m[[i, j]] = if i == j {
939                    F::one() - l_sym[[i, j]]
940                } else {
941                    F::zero() - l_sym[[i, j]]
942                };
943            }
944        }
945        m
946    };
947
948    let mut eigvecs = Array2::<F>::zeros((n, k));
949    let mut deflated = shifted.clone();
950
951    for kk in 0..k {
952        let v = power_iteration(&deflated, 100)?;
953        for i in 0..n {
954            eigvecs[[i, kk]] = v[i];
955        }
956        // Deflate: remove component along v
957        let eigenval = rayleigh_quotient(&deflated, &v);
958        for i in 0..n {
959            for j in 0..n {
960                deflated[[i, j]] = deflated[[i, j]] - eigenval * v[i] * v[j];
961            }
962        }
963    }
964
965    // Row-normalise eigenvectors
966    for i in 0..n {
967        let mut norm = F::zero();
968        for kk in 0..k {
969            norm = norm + eigvecs[[i, kk]] * eigvecs[[i, kk]];
970        }
971        let norm = norm.sqrt();
972        if norm > F::epsilon() {
973            for kk in 0..k {
974                eigvecs[[i, kk]] = eigvecs[[i, kk]] / norm;
975            }
976        }
977    }
978
979    // K-means on the rows of eigvecs
980    let labels = simple_kmeans(eigvecs.view(), k, 50);
981    Ok(labels)
982}
983
984/// Power iteration to find the dominant eigenvector.
985fn power_iteration<F: Float + FromPrimitive + Debug>(
986    mat: &Array2<F>,
987    max_iter: usize,
988) -> Result<Array1<F>> {
989    let n = mat.shape()[0];
990    let mut v = Array1::<F>::zeros(n);
991    // Initialize with alternating signs for better convergence
992    for i in 0..n {
993        v[i] = if i % 2 == 0 {
994            F::one()
995        } else {
996            F::zero() - F::one()
997        };
998    }
999    // Normalize
1000    let mut norm = F::zero();
1001    for i in 0..n {
1002        norm = norm + v[i] * v[i];
1003    }
1004    let norm = norm.sqrt();
1005    if norm > F::epsilon() {
1006        for i in 0..n {
1007            v[i] = v[i] / norm;
1008        }
1009    }
1010
1011    for _ in 0..max_iter {
1012        // w = M * v
1013        let mut w = Array1::<F>::zeros(n);
1014        for i in 0..n {
1015            for j in 0..n {
1016                w[i] = w[i] + mat[[i, j]] * v[j];
1017            }
1018        }
1019        // Normalize
1020        let mut norm = F::zero();
1021        for i in 0..n {
1022            norm = norm + w[i] * w[i];
1023        }
1024        let norm = norm.sqrt();
1025        if norm < F::epsilon() {
1026            break;
1027        }
1028        for i in 0..n {
1029            v[i] = w[i] / norm;
1030        }
1031    }
1032    Ok(v)
1033}
1034
1035/// Rayleigh quotient v^T M v / v^T v.
1036fn rayleigh_quotient<F: Float>(mat: &Array2<F>, v: &Array1<F>) -> F {
1037    let n = v.len();
1038    let mut num = F::zero();
1039    let mut den = F::zero();
1040    for i in 0..n {
1041        den = den + v[i] * v[i];
1042        for j in 0..n {
1043            num = num + v[i] * mat[[i, j]] * v[j];
1044        }
1045    }
1046    if den > F::epsilon() {
1047        num / den
1048    } else {
1049        F::zero()
1050    }
1051}
1052
1053/// Simple k-means for spectral clustering post-processing.
1054fn simple_kmeans<F: Float + FromPrimitive + Debug>(
1055    data: ArrayView2<F>,
1056    k: usize,
1057    max_iter: usize,
1058) -> Array1<i32> {
1059    let (n, d) = (data.shape()[0], data.shape()[1]);
1060    if n == 0 || k == 0 {
1061        return Array1::from_elem(n, -1i32);
1062    }
1063
1064    // Initialize centroids from first k distinct rows
1065    let mut centroids = Array2::<F>::zeros((k, d));
1066    let step = (n as f64 / k as f64).max(1.0);
1067    for ci in 0..k {
1068        let idx = ((ci as f64 * step) as usize).min(n - 1);
1069        centroids.row_mut(ci).assign(&data.row(idx));
1070    }
1071
1072    let mut labels = Array1::from_elem(n, 0i32);
1073
1074    for _ in 0..max_iter {
1075        // Assign
1076        let mut changed = false;
1077        for i in 0..n {
1078            let mut best = 0i32;
1079            let mut best_d = f64::MAX;
1080            for ci in 0..k {
1081                let dd = euclidean_sq_f64(data.row(i), centroids.row(ci));
1082                if dd < best_d {
1083                    best_d = dd;
1084                    best = ci as i32;
1085                }
1086            }
1087            if labels[i] != best {
1088                labels[i] = best;
1089                changed = true;
1090            }
1091        }
1092        if !changed {
1093            break;
1094        }
1095
1096        // Update centroids
1097        let mut counts = vec![0usize; k];
1098        let mut sums = Array2::<F>::zeros((k, d));
1099        for i in 0..n {
1100            let ci = labels[i] as usize;
1101            counts[ci] += 1;
1102            for dd in 0..d {
1103                sums[[ci, dd]] = sums[[ci, dd]] + data[[i, dd]];
1104            }
1105        }
1106        for ci in 0..k {
1107            if counts[ci] > 0 {
1108                let cnt = F::from(counts[ci]).unwrap_or_else(|| F::one());
1109                for dd in 0..d {
1110                    centroids[[ci, dd]] = sums[[ci, dd]] / cnt;
1111                }
1112            }
1113        }
1114    }
1115
1116    labels
1117}
1118
1119/// Squared Euclidean distance as f64.
1120fn euclidean_sq_f64<F: Float>(a: ArrayView1<F>, b: ArrayView1<F>) -> f64 {
1121    let mut s = 0.0f64;
1122    for i in 0..a.len() {
1123        let d = (a[i] - b[i]).to_f64().unwrap_or(0.0);
1124        s += d * d;
1125    }
1126    s
1127}
1128
1129// ---------------------------------------------------------------------------
1130// Feature subspace selection
1131// ---------------------------------------------------------------------------
1132
1133/// Method for feature subspace selection.
1134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1135pub enum SubspaceSelectionMethod {
1136    /// Variance-based: select top-k highest variance features.
1137    Variance,
1138    /// Entropy-based: select top-k highest entropy features (discretised).
1139    Entropy,
1140    /// Correlation-based: greedily select features that are mutually uncorrelated.
1141    CorrelationFiltering,
1142}
1143
1144/// Select the best feature subspace.
1145///
1146/// Returns sorted indices of the selected features.
1147pub fn select_subspace<F: Float + FromPrimitive + Debug>(
1148    data: ArrayView2<F>,
1149    k: usize,
1150    method: SubspaceSelectionMethod,
1151) -> Result<Vec<usize>> {
1152    let n_features = data.shape()[1];
1153    if k == 0 || k > n_features {
1154        return Err(ClusteringError::InvalidInput(
1155            "k must be in [1, n_features]".into(),
1156        ));
1157    }
1158
1159    match method {
1160        SubspaceSelectionMethod::Variance => select_by_variance(data, k),
1161        SubspaceSelectionMethod::Entropy => select_by_entropy(data, k),
1162        SubspaceSelectionMethod::CorrelationFiltering => select_by_correlation(data, k),
1163    }
1164}
1165
1166fn select_by_variance<F: Float + FromPrimitive + Debug>(
1167    data: ArrayView2<F>,
1168    k: usize,
1169) -> Result<Vec<usize>> {
1170    let (n, d) = (data.shape()[0], data.shape()[1]);
1171    if n < 2 {
1172        let mut r: Vec<usize> = (0..k.min(d)).collect();
1173        return Ok(r);
1174    }
1175
1176    let mut variances: Vec<(usize, f64)> = Vec::with_capacity(d);
1177    for dim in 0..d {
1178        let mean = (0..n)
1179            .map(|i| data[[i, dim]].to_f64().unwrap_or(0.0))
1180            .sum::<f64>()
1181            / n as f64;
1182        let var = (0..n)
1183            .map(|i| {
1184                let diff = data[[i, dim]].to_f64().unwrap_or(0.0) - mean;
1185                diff * diff
1186            })
1187            .sum::<f64>()
1188            / (n - 1) as f64;
1189        variances.push((dim, var));
1190    }
1191    variances.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1192    let mut result: Vec<usize> = variances.iter().take(k).map(|&(d, _)| d).collect();
1193    result.sort();
1194    Ok(result)
1195}
1196
1197fn select_by_entropy<F: Float + FromPrimitive + Debug>(
1198    data: ArrayView2<F>,
1199    k: usize,
1200) -> Result<Vec<usize>> {
1201    let (n, d) = (data.shape()[0], data.shape()[1]);
1202    let n_bins = 10usize;
1203
1204    let mut entropies: Vec<(usize, f64)> = Vec::with_capacity(d);
1205    for dim in 0..d {
1206        let mut min_v = f64::MAX;
1207        let mut max_v = f64::MIN;
1208        for i in 0..n {
1209            let v = data[[i, dim]].to_f64().unwrap_or(0.0);
1210            if v < min_v {
1211                min_v = v;
1212            }
1213            if v > max_v {
1214                max_v = v;
1215            }
1216        }
1217        let range = (max_v - min_v).max(1e-15);
1218        let mut counts = vec![0usize; n_bins];
1219        for i in 0..n {
1220            let v = data[[i, dim]].to_f64().unwrap_or(0.0);
1221            let bin = (((v - min_v) / range) * (n_bins as f64 - 1e-10)) as usize;
1222            counts[bin.min(n_bins - 1)] += 1;
1223        }
1224        let entropy: f64 = counts
1225            .iter()
1226            .filter(|&&c| c > 0)
1227            .map(|&c| {
1228                let p = c as f64 / n as f64;
1229                -p * p.ln()
1230            })
1231            .sum();
1232        entropies.push((dim, entropy));
1233    }
1234    entropies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1235    let mut result: Vec<usize> = entropies.iter().take(k).map(|&(d, _)| d).collect();
1236    result.sort();
1237    Ok(result)
1238}
1239
1240fn select_by_correlation<F: Float + FromPrimitive + Debug>(
1241    data: ArrayView2<F>,
1242    k: usize,
1243) -> Result<Vec<usize>> {
1244    let (n, d) = (data.shape()[0], data.shape()[1]);
1245
1246    // Compute means and stddevs
1247    let mut means = vec![0.0f64; d];
1248    for i in 0..n {
1249        for dim in 0..d {
1250            means[dim] += data[[i, dim]].to_f64().unwrap_or(0.0);
1251        }
1252    }
1253    for dim in 0..d {
1254        means[dim] /= n as f64;
1255    }
1256    let mut stds = vec![0.0f64; d];
1257    for i in 0..n {
1258        for dim in 0..d {
1259            let diff = data[[i, dim]].to_f64().unwrap_or(0.0) - means[dim];
1260            stds[dim] += diff * diff;
1261        }
1262    }
1263    for dim in 0..d {
1264        stds[dim] = (stds[dim] / n.max(1) as f64).sqrt().max(1e-15);
1265    }
1266
1267    // Variance ranking for initial feature
1268    let mut variances: Vec<(usize, f64)> =
1269        stds.iter().enumerate().map(|(d, &s)| (d, s * s)).collect();
1270    variances.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1271
1272    let mut selected = vec![variances[0].0];
1273
1274    while selected.len() < k {
1275        let mut best_dim = None;
1276        let mut best_score = f64::MAX;
1277        for &(dim, _) in &variances {
1278            if selected.contains(&dim) {
1279                continue;
1280            }
1281            // Max absolute correlation with any selected feature
1282            let mut max_corr = 0.0f64;
1283            for &s in &selected {
1284                let mut cov = 0.0f64;
1285                for i in 0..n {
1286                    let a = data[[i, dim]].to_f64().unwrap_or(0.0) - means[dim];
1287                    let b = data[[i, s]].to_f64().unwrap_or(0.0) - means[s];
1288                    cov += a * b;
1289                }
1290                cov /= n.max(1) as f64;
1291                let corr = (cov / (stds[dim] * stds[s])).abs();
1292                if corr > max_corr {
1293                    max_corr = corr;
1294                }
1295            }
1296            if max_corr < best_score {
1297                best_score = max_corr;
1298                best_dim = Some(dim);
1299            }
1300        }
1301        match best_dim {
1302            Some(d) => selected.push(d),
1303            None => break,
1304        }
1305    }
1306
1307    selected.sort();
1308    Ok(selected)
1309}
1310
1311// ---------------------------------------------------------------------------
1312// Subspace quality metrics
1313// ---------------------------------------------------------------------------
1314
1315/// Subspace clustering quality metrics.
1316#[derive(Debug, Clone)]
1317pub struct SubspaceQuality {
1318    /// Coverage: fraction of points assigned to a cluster.
1319    pub coverage: f64,
1320    /// Average cluster density (points per cluster per dimension).
1321    pub avg_density: f64,
1322    /// Subspace dimensionality ratio (avg dims used / total dims).
1323    pub dimensionality_ratio: f64,
1324    /// Cluster separation in subspace (average inter-cluster distance).
1325    pub separation: f64,
1326}
1327
1328/// Evaluate the quality of a subspace clustering result.
1329pub fn subspace_quality<F: Float + FromPrimitive + Debug>(
1330    data: ArrayView2<F>,
1331    labels: &Array1<i32>,
1332    cluster_dims: &[Vec<usize>],
1333) -> Result<SubspaceQuality> {
1334    let (n, d) = (data.shape()[0], data.shape()[1]);
1335
1336    if labels.len() != n {
1337        return Err(ClusteringError::InvalidInput(
1338            "labels length must match n_samples".into(),
1339        ));
1340    }
1341
1342    let n_assigned = labels.iter().filter(|&&l| l >= 0).count();
1343    let coverage = n_assigned as f64 / n.max(1) as f64;
1344
1345    let k = cluster_dims.len();
1346    let avg_dims: f64 = if k > 0 {
1347        cluster_dims.iter().map(|d| d.len() as f64).sum::<f64>() / k as f64
1348    } else {
1349        0.0
1350    };
1351    let dimensionality_ratio = avg_dims / d.max(1) as f64;
1352
1353    // Compute per-cluster count and centroids in projected subspace
1354    let mut counts = vec![0usize; k];
1355    for &l in labels.iter() {
1356        if l >= 0 && (l as usize) < k {
1357            counts[l as usize] += 1;
1358        }
1359    }
1360
1361    let avg_density = if k > 0 && avg_dims > 0.0 {
1362        let total_assigned: usize = counts.iter().sum();
1363        total_assigned as f64 / (k as f64 * avg_dims)
1364    } else {
1365        0.0
1366    };
1367
1368    // Compute centroids for separation
1369    let mut centroids: Vec<Vec<f64>> = vec![vec![0.0; d]; k];
1370    for i in 0..n {
1371        let l = labels[i];
1372        if l >= 0 && (l as usize) < k {
1373            let ci = l as usize;
1374            for dd in 0..d {
1375                centroids[ci][dd] += data[[i, dd]].to_f64().unwrap_or(0.0);
1376            }
1377        }
1378    }
1379    for ci in 0..k {
1380        if counts[ci] > 0 {
1381            for dd in 0..d {
1382                centroids[ci][dd] /= counts[ci] as f64;
1383            }
1384        }
1385    }
1386
1387    let mut separation = 0.0f64;
1388    let mut sep_count = 0usize;
1389    for ci in 0..k {
1390        for cj in (ci + 1)..k {
1391            let mut dist = 0.0f64;
1392            // Use union of both cluster dimensions
1393            let mut union_dims: HashSet<usize> = HashSet::new();
1394            for &d in &cluster_dims[ci] {
1395                union_dims.insert(d);
1396            }
1397            for &d in &cluster_dims[cj] {
1398                union_dims.insert(d);
1399            }
1400            for &dd in &union_dims {
1401                let diff = centroids[ci][dd] - centroids[cj][dd];
1402                dist += diff * diff;
1403            }
1404            separation += dist.sqrt();
1405            sep_count += 1;
1406        }
1407    }
1408    if sep_count > 0 {
1409        separation /= sep_count as f64;
1410    }
1411
1412    Ok(SubspaceQuality {
1413        coverage,
1414        avg_density,
1415        dimensionality_ratio,
1416        separation,
1417    })
1418}
1419
1420// ---------------------------------------------------------------------------
1421// Tests
1422// ---------------------------------------------------------------------------
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427    use scirs2_core::ndarray::Array2;
1428
1429    fn make_two_cluster_data() -> Array2<f64> {
1430        // Cluster A at (1,1,0,0), cluster B at (5,5,0,0) — clusters in dims 0,1
1431        let mut data = Vec::new();
1432        for _ in 0..20 {
1433            data.extend_from_slice(&[1.0, 1.0, 0.5, 0.5]);
1434        }
1435        for _ in 0..20 {
1436            data.extend_from_slice(&[5.0, 5.0, 0.5, 0.5]);
1437        }
1438        // Add some small noise deterministically
1439        let mut arr = Array2::from_shape_vec((40, 4), data).expect("shape construction failed");
1440        for i in 0..40 {
1441            let noise = (i as f64 * 0.037).sin() * 0.2;
1442            arr[[i, 0]] = arr[[i, 0]] + noise;
1443            arr[[i, 1]] = arr[[i, 1]] + noise * 0.5;
1444        }
1445        arr
1446    }
1447
1448    #[test]
1449    fn test_clique_basic() {
1450        let data = make_two_cluster_data();
1451        let config = CliqueConfig {
1452            n_intervals: 5,
1453            density_threshold: 0.1,
1454            max_subspace_dim: 2,
1455        };
1456        let result = clique(data.view(), &config).expect("clique failed");
1457        // Should find at least 1 cluster
1458        assert!(
1459            result.n_clusters >= 1,
1460            "Expected at least 1 cluster, got {}",
1461            result.n_clusters
1462        );
1463        // Most points should be assigned
1464        let assigned = result.labels.iter().filter(|&&l| l >= 0).count();
1465        assert!(
1466            assigned > 20,
1467            "Expected most points assigned, got {}",
1468            assigned
1469        );
1470    }
1471
1472    #[test]
1473    fn test_clique_empty_data() {
1474        let data = Array2::<f64>::zeros((0, 4));
1475        let config = CliqueConfig::default();
1476        assert!(clique(data.view(), &config).is_err());
1477    }
1478
1479    #[test]
1480    fn test_clique_invalid_params() {
1481        let data = make_two_cluster_data();
1482        let config = CliqueConfig {
1483            n_intervals: 0,
1484            ..Default::default()
1485        };
1486        assert!(clique(data.view(), &config).is_err());
1487
1488        let config2 = CliqueConfig {
1489            density_threshold: 0.0,
1490            ..Default::default()
1491        };
1492        assert!(clique(data.view(), &config2).is_err());
1493    }
1494
1495    #[test]
1496    fn test_proclus_basic() {
1497        let data = make_two_cluster_data();
1498        let config = ProclusConfig {
1499            n_clusters: 2,
1500            avg_dimensions: 2,
1501            max_iterations: 20,
1502            ..Default::default()
1503        };
1504        let result = proclus(data.view(), &config).expect("proclus failed");
1505        assert_eq!(result.labels.len(), 40);
1506        assert_eq!(result.medoids.len(), 2);
1507        assert_eq!(result.cluster_dimensions.len(), 2);
1508        // Each cluster should have dimensions
1509        for dims in &result.cluster_dimensions {
1510            assert!(!dims.is_empty());
1511        }
1512    }
1513
1514    #[test]
1515    fn test_proclus_empty_data() {
1516        let data = Array2::<f64>::zeros((0, 3));
1517        let config = ProclusConfig::default();
1518        assert!(proclus(data.view(), &config).is_err());
1519    }
1520
1521    #[test]
1522    fn test_proclus_invalid_k() {
1523        let data = make_two_cluster_data();
1524        let config = ProclusConfig {
1525            n_clusters: 0,
1526            ..Default::default()
1527        };
1528        assert!(proclus(data.view(), &config).is_err());
1529    }
1530
1531    #[test]
1532    fn test_ssc_basic() {
1533        // Small dataset for SSC (it's O(n^2) in memory)
1534        let data = Array2::from_shape_vec(
1535            (12, 3),
1536            vec![
1537                1.0, 0.0, 0.0, 1.1, 0.1, 0.0, 0.9, -0.1, 0.0, 1.2, 0.05, 0.0, 0.0, 1.0, 0.0, 0.1,
1538                1.1, 0.0, -0.1, 0.9, 0.0, 0.05, 1.2, 0.0, 0.0, 0.0, 1.0, 0.0, 0.1, 1.1, 0.0, -0.1,
1539                0.9, 0.0, 0.05, 1.2,
1540            ],
1541        )
1542        .expect("shape failed");
1543
1544        let config = SscConfig {
1545            n_clusters: 3,
1546            lambda: 0.5,
1547            max_iterations: 50,
1548            ..Default::default()
1549        };
1550        let result = ssc(data.view(), &config).expect("ssc failed");
1551        assert_eq!(result.labels.len(), 12);
1552        assert_eq!(result.n_clusters, 3);
1553        // Affinity should be symmetric
1554        let n = result.affinity.shape()[0];
1555        for i in 0..n {
1556            for j in 0..n {
1557                let diff = (result.affinity[[i, j]] - result.affinity[[j, i]]).abs();
1558                assert!(diff < 1e-10);
1559            }
1560        }
1561    }
1562
1563    #[test]
1564    fn test_ssc_empty_data() {
1565        let data = Array2::<f64>::zeros((0, 3));
1566        let config = SscConfig::default();
1567        assert!(ssc(data.view(), &config).is_err());
1568    }
1569
1570    #[test]
1571    fn test_select_subspace_variance() {
1572        let data = make_two_cluster_data();
1573        let selected = select_subspace(data.view(), 2, SubspaceSelectionMethod::Variance)
1574            .expect("variance selection failed");
1575        assert_eq!(selected.len(), 2);
1576        // Dims 0 and 1 have the most variance (clusters spread there)
1577        assert!(selected.contains(&0));
1578        assert!(selected.contains(&1));
1579    }
1580
1581    #[test]
1582    fn test_select_subspace_entropy() {
1583        let data = make_two_cluster_data();
1584        let selected = select_subspace(data.view(), 2, SubspaceSelectionMethod::Entropy)
1585            .expect("entropy selection failed");
1586        assert_eq!(selected.len(), 2);
1587    }
1588
1589    #[test]
1590    fn test_select_subspace_correlation() {
1591        let data = make_two_cluster_data();
1592        let selected = select_subspace(
1593            data.view(),
1594            2,
1595            SubspaceSelectionMethod::CorrelationFiltering,
1596        )
1597        .expect("correlation selection failed");
1598        assert_eq!(selected.len(), 2);
1599    }
1600
1601    #[test]
1602    fn test_select_subspace_invalid_k() {
1603        let data = make_two_cluster_data();
1604        assert!(select_subspace(data.view(), 0, SubspaceSelectionMethod::Variance).is_err());
1605        assert!(select_subspace(data.view(), 100, SubspaceSelectionMethod::Variance).is_err());
1606    }
1607
1608    #[test]
1609    fn test_subspace_quality() {
1610        let data = make_two_cluster_data();
1611        let labels = Array1::from_vec((0..20).map(|_| 0i32).chain((0..20).map(|_| 1i32)).collect());
1612        let cluster_dims = vec![vec![0, 1], vec![0, 1]];
1613        let quality =
1614            subspace_quality(data.view(), &labels, &cluster_dims).expect("quality failed");
1615        assert!((quality.coverage - 1.0).abs() < 1e-10);
1616        assert!(quality.dimensionality_ratio > 0.0);
1617        assert!(quality.separation > 0.0);
1618    }
1619
1620    #[test]
1621    fn test_subspace_quality_with_noise() {
1622        let data = make_two_cluster_data();
1623        let mut labels_vec: Vec<i32> = (0..20).map(|_| 0).chain((0..20).map(|_| 1)).collect();
1624        labels_vec[0] = -1; // one noise point
1625        let labels = Array1::from_vec(labels_vec);
1626        let cluster_dims = vec![vec![0, 1], vec![0, 1]];
1627        let quality =
1628            subspace_quality(data.view(), &labels, &cluster_dims).expect("quality failed");
1629        assert!(quality.coverage < 1.0);
1630    }
1631
1632    #[test]
1633    fn test_soft_threshold() {
1634        assert!((soft_threshold(1.5, 1.0) - 0.5).abs() < 1e-10);
1635        assert!((soft_threshold(-1.5, 1.0) - (-0.5)).abs() < 1e-10);
1636        assert!((soft_threshold(0.5, 1.0)).abs() < 1e-10);
1637    }
1638
1639    #[test]
1640    fn test_clique_single_point() {
1641        let data = Array2::from_shape_vec((1, 2), vec![1.0, 2.0]).expect("shape failed");
1642        let config = CliqueConfig {
1643            n_intervals: 5,
1644            density_threshold: 0.5,
1645            ..Default::default()
1646        };
1647        let result = clique(data.view(), &config);
1648        assert!(result.is_ok());
1649    }
1650
1651    #[test]
1652    fn test_proclus_single_cluster() {
1653        let data = Array2::from_shape_vec((10, 3), (0..30).map(|i| (i as f64) * 0.1).collect())
1654            .expect("shape failed");
1655        let config = ProclusConfig {
1656            n_clusters: 1,
1657            avg_dimensions: 2,
1658            ..Default::default()
1659        };
1660        let result = proclus(data.view(), &config).expect("proclus failed");
1661        // All should be cluster 0
1662        for &l in result.labels.iter() {
1663            assert_eq!(l, 0);
1664        }
1665    }
1666
1667    #[test]
1668    fn test_euclidean_sq_f64() {
1669        let a = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1670        let b = Array1::from_vec(vec![4.0, 5.0, 6.0]);
1671        let d = euclidean_sq_f64(a.view(), b.view());
1672        assert!((d - 27.0).abs() < 1e-10);
1673    }
1674
1675    #[test]
1676    fn test_clique_f32() {
1677        let data = Array2::<f32>::from_shape_vec(
1678            (20, 2),
1679            (0..40)
1680                .map(|i| if i < 20 { 1.0f32 } else { 5.0f32 })
1681                .collect(),
1682        )
1683        .expect("shape failed");
1684        let config = CliqueConfig {
1685            n_intervals: 3,
1686            density_threshold: 0.2,
1687            ..Default::default()
1688        };
1689        let result = clique(data.view(), &config);
1690        assert!(result.is_ok());
1691    }
1692}