Skip to main content

rust_igraph/algorithms/layout/
mds.rs

1//! Multidimensional Scaling layout (ALGO-LO-010).
2//!
3//! Classical MDS (Torgerson 1952): place vertices so that pairwise
4//! Euclidean distances approximate given dissimilarities. Uses
5//! double centering + eigendecomposition of the Gram matrix.
6//!
7//! Reference: Cox & Cox, "Multidimensional Scaling" (1994), Chapman & Hall.
8
9use crate::algorithms::paths::floyd_warshall::floyd_warshall_distances;
10use crate::core::{Graph, IgraphError, IgraphResult};
11
12/// Compute the MDS (Multidimensional Scaling) layout.
13///
14/// Places vertices so that Euclidean distances in 2D approximate the
15/// graph-theoretic distances. Uses classical (metric) MDS via double
16/// centering of the squared distance matrix and eigendecomposition.
17///
18/// For disconnected graphs, each component is laid out independently
19/// and the resulting layouts are placed side by side.
20///
21/// # Arguments
22///
23/// * `graph` — input graph (edge directions ignored for distances).
24/// * `dist` — optional distance matrix (row-major, n×n). If `None`,
25///   shortest-path distances are computed automatically.
26///
27/// Returns `[x, y]` positions for each vertex.
28///
29/// # Errors
30///
31/// Returns `InvalidArgument` if the distance matrix dimensions don't
32/// match the vertex count.
33///
34/// # Examples
35///
36/// ```
37/// use rust_igraph::{Graph, layout_mds};
38///
39/// let mut g = Graph::with_vertices(5);
40/// g.add_edge(0, 1).unwrap();
41/// g.add_edge(1, 2).unwrap();
42/// g.add_edge(2, 3).unwrap();
43/// g.add_edge(3, 4).unwrap();
44///
45/// let pos = layout_mds(&g, None).unwrap();
46/// assert_eq!(pos.len(), 5);
47/// assert!(pos.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
48/// ```
49pub fn layout_mds(graph: &Graph, dist: Option<&[Vec<f64>]>) -> IgraphResult<Vec<[f64; 2]>> {
50    let n = graph.vcount() as usize;
51    if n == 0 {
52        return Ok(Vec::new());
53    }
54    if n == 1 {
55        return Ok(vec![[0.0, 0.0]]);
56    }
57
58    if let Some(d) = dist {
59        if d.len() != n {
60            return Err(IgraphError::InvalidArgument(format!(
61                "distance matrix rows ({}) must equal vertex count ({})",
62                d.len(),
63                n
64            )));
65        }
66        for row in d {
67            if row.len() != n {
68                return Err(IgraphError::InvalidArgument(format!(
69                    "distance matrix columns ({}) must equal vertex count ({})",
70                    row.len(),
71                    n
72                )));
73            }
74        }
75    }
76
77    // Get distance matrix
78    let dist_matrix = if let Some(d) = dist {
79        d.to_vec()
80    } else {
81        // Use Floyd-Warshall for all-pairs unweighted shortest paths
82        let fw = floyd_warshall_distances(graph, None)?;
83        fw.into_iter()
84            .map(|row| {
85                row.into_iter()
86                    .map(|v| v.unwrap_or(f64::INFINITY))
87                    .collect()
88            })
89            .collect()
90    };
91
92    // Check for disconnected graph (infinite distances)
93    let connected = dist_matrix
94        .iter()
95        .all(|row| row.iter().all(|&v| v.is_finite()));
96
97    if connected {
98        mds_single(n, &dist_matrix)
99    } else {
100        mds_disconnected(n, &dist_matrix)
101    }
102}
103
104fn mds_single(n: usize, dist: &[Vec<f64>]) -> IgraphResult<Vec<[f64; 2]>> {
105    if n == 1 {
106        return Ok(vec![[0.0, 0.0]]);
107    }
108    if n == 2 {
109        return Ok(vec![[0.0, 0.0], [dist[0][1].max(1.0), 0.0]]);
110    }
111
112    // Square the distances
113    let mut b: Vec<Vec<f64>> = vec![vec![0.0; n]; n];
114    for i in 0..n {
115        for j in 0..n {
116            b[i][j] = dist[i][j] * dist[i][j];
117        }
118    }
119
120    // Double centering: B = -0.5 * (D² - row_mean - col_mean + grand_mean)
121    let mut row_means = vec![0.0_f64; n];
122    for i in 0..n {
123        let sum: f64 = b[i].iter().sum();
124        row_means[i] = sum / n as f64;
125    }
126
127    let grand_mean: f64 = row_means.iter().sum::<f64>() / n as f64;
128
129    for i in 0..n {
130        for j in 0..n {
131            b[i][j] = -0.5 * (b[i][j] - row_means[i] - row_means[j] + grand_mean);
132        }
133    }
134
135    // Extract top 2 eigenvectors via power iteration
136    let (eigenvalues, eigenvectors) = top_k_eigen_symmetric(&b, 2);
137
138    // Scale eigenvectors by sqrt(|eigenvalue|)
139    let mut pos = vec![[0.0_f64; 2]; n];
140    for dim in 0..2 {
141        let scale = eigenvalues[dim].abs().sqrt();
142        for i in 0..n {
143            pos[i][dim] = scale * eigenvectors[dim][i];
144        }
145    }
146
147    Ok(pos)
148}
149
150fn mds_disconnected(n: usize, dist: &[Vec<f64>]) -> IgraphResult<Vec<[f64; 2]>> {
151    // Find connected components via BFS
152    let mut component = vec![usize::MAX; n];
153    let mut comp_id = 0;
154    let mut components: Vec<Vec<usize>> = Vec::new();
155
156    for start in 0..n {
157        if component[start] != usize::MAX {
158            continue;
159        }
160        let mut members = Vec::new();
161        let mut queue = std::collections::VecDeque::new();
162        queue.push_back(start);
163        component[start] = comp_id;
164        while let Some(v) = queue.pop_front() {
165            members.push(v);
166            for j in 0..n {
167                if component[j] == usize::MAX && dist[v][j].is_finite() && dist[v][j] > 0.0 {
168                    component[j] = comp_id;
169                    queue.push_back(j);
170                }
171            }
172        }
173        components.push(members);
174        comp_id += 1;
175    }
176
177    // Lay out each component separately
178    let mut pos = vec![[0.0_f64; 2]; n];
179    let mut x_offset = 0.0_f64;
180
181    for comp in &components {
182        let cn = comp.len();
183        if cn == 1 {
184            pos[comp[0]] = [x_offset, 0.0];
185            x_offset += 2.0;
186            continue;
187        }
188
189        // Extract sub-distance-matrix
190        let mut sub_dist = vec![vec![0.0_f64; cn]; cn];
191        for (li, &vi) in comp.iter().enumerate() {
192            for (lj, &vj) in comp.iter().enumerate() {
193                sub_dist[li][lj] = dist[vi][vj];
194            }
195        }
196
197        let sub_pos = mds_single(cn, &sub_dist)?;
198
199        // Find bounding box of sub_pos
200        let mut min_x = f64::INFINITY;
201        let mut max_x = f64::NEG_INFINITY;
202        for p in &sub_pos {
203            if p[0] < min_x {
204                min_x = p[0];
205            }
206            if p[0] > max_x {
207                max_x = p[0];
208            }
209        }
210
211        // Place component
212        for (li, &vi) in comp.iter().enumerate() {
213            pos[vi][0] = sub_pos[li][0] - min_x + x_offset;
214            pos[vi][1] = sub_pos[li][1];
215        }
216
217        x_offset += (max_x - min_x) + 2.0;
218    }
219
220    Ok(pos)
221}
222
223/// Extract top-k eigenpairs of a symmetric matrix using power iteration
224/// with deflation. Returns (eigenvalues, eigenvectors) sorted by
225/// decreasing eigenvalue magnitude.
226fn top_k_eigen_symmetric(matrix: &[Vec<f64>], k: usize) -> (Vec<f64>, Vec<Vec<f64>>) {
227    let n = matrix.len();
228    let k = k.min(n);
229    let max_iter = 300;
230    let tol = 1e-10;
231
232    let mut eigenvalues = Vec::with_capacity(k);
233    let mut eigenvectors: Vec<Vec<f64>> = Vec::with_capacity(k);
234
235    // Work on a mutable copy for deflation
236    let mut work: Vec<Vec<f64>> = matrix.to_vec();
237
238    let mut rng = SplitMix64::new(31415);
239
240    for _ev in 0..k {
241        // Random initial vector
242        let mut v: Vec<f64> = (0..n).map(|_| rng.next_uniform() - 0.5).collect();
243        normalize(&mut v);
244
245        let mut eigenvalue = 0.0_f64;
246
247        for _iter in 0..max_iter {
248            // Matrix-vector multiply: w = A * v
249            let mut w = vec![0.0_f64; n];
250            for i in 0..n {
251                let mut sum = 0.0_f64;
252                for j in 0..n {
253                    sum += work[i][j] * v[j];
254                }
255                w[i] = sum;
256            }
257
258            // Rayleigh quotient
259            let new_eigenvalue: f64 = v.iter().zip(w.iter()).map(|(a, b)| a * b).sum();
260
261            normalize(&mut w);
262
263            // Check convergence
264            let diff: f64 = v.iter().zip(w.iter()).map(|(a, b)| (a - b) * (a - b)).sum();
265            v = w;
266            eigenvalue = new_eigenvalue;
267
268            if diff < tol {
269                break;
270            }
271        }
272
273        // Deflate: A = A - lambda * v * v^T
274        for i in 0..n {
275            for j in 0..n {
276                work[i][j] -= eigenvalue * v[i] * v[j];
277            }
278        }
279
280        eigenvalues.push(eigenvalue);
281        eigenvectors.push(v);
282    }
283
284    (eigenvalues, eigenvectors)
285}
286
287fn normalize(v: &mut [f64]) {
288    let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
289    if norm > 0.0 {
290        for x in v.iter_mut() {
291            *x /= norm;
292        }
293    }
294}
295
296// ═══════════════════════════════════════════════════════════════════
297// Internal RNG
298// ═══════════════════════════════════════════════════════════════════
299
300struct SplitMix64 {
301    state: u64,
302}
303
304impl SplitMix64 {
305    fn new(seed: u64) -> Self {
306        Self { state: seed }
307    }
308
309    fn next_u64(&mut self) -> u64 {
310        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
311        let mut z = self.state;
312        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
313        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
314        z ^ (z >> 31)
315    }
316
317    fn next_uniform(&mut self) -> f64 {
318        (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
319    }
320}
321
322// ═══════════════════════════════════════════════════════════════════
323// Tests
324// ═══════════════════════════════════════════════════════════════════
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_mds_empty() {
332        let g = Graph::with_vertices(0);
333        let pos = layout_mds(&g, None).unwrap();
334        assert!(pos.is_empty());
335    }
336
337    #[test]
338    fn test_mds_single() {
339        let g = Graph::with_vertices(1);
340        let pos = layout_mds(&g, None).unwrap();
341        assert_eq!(pos.len(), 1);
342        assert!(pos[0][0].abs() < 1e-10 && pos[0][1].abs() < 1e-10);
343    }
344
345    #[test]
346    fn test_mds_two_vertices() {
347        let mut g = Graph::with_vertices(2);
348        g.add_edge(0, 1).unwrap();
349        let pos = layout_mds(&g, None).unwrap();
350        assert_eq!(pos.len(), 2);
351        assert!(pos[0][0].is_finite() && pos[1][0].is_finite());
352        // Two vertices should be separated
353        let dx = pos[0][0] - pos[1][0];
354        let dy = pos[0][1] - pos[1][1];
355        assert!((dx * dx + dy * dy).sqrt() > 0.1);
356    }
357
358    #[test]
359    fn test_mds_path() {
360        let mut g = Graph::with_vertices(5);
361        for i in 0..4 {
362            g.add_edge(i, i + 1).unwrap();
363        }
364        let pos = layout_mds(&g, None).unwrap();
365        assert_eq!(pos.len(), 5);
366        for p in &pos {
367            assert!(p[0].is_finite() && p[1].is_finite());
368        }
369    }
370
371    #[test]
372    fn test_mds_cycle() {
373        let mut g = Graph::with_vertices(6);
374        for i in 0..6 {
375            g.add_edge(i, (i + 1) % 6).unwrap();
376        }
377        let pos = layout_mds(&g, None).unwrap();
378        assert_eq!(pos.len(), 6);
379        for p in &pos {
380            assert!(p[0].is_finite() && p[1].is_finite());
381        }
382    }
383
384    #[test]
385    fn test_mds_with_dist_matrix() {
386        let g = Graph::with_vertices(3);
387        let dist = vec![
388            vec![0.0, 1.0, 2.0],
389            vec![1.0, 0.0, 1.0],
390            vec![2.0, 1.0, 0.0],
391        ];
392        let pos = layout_mds(&g, Some(&dist)).unwrap();
393        assert_eq!(pos.len(), 3);
394        for p in &pos {
395            assert!(p[0].is_finite() && p[1].is_finite());
396        }
397    }
398
399    #[test]
400    fn test_mds_wrong_dist_size() {
401        let g = Graph::with_vertices(3);
402        let dist = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
403        assert!(layout_mds(&g, Some(&dist)).is_err());
404    }
405
406    #[test]
407    fn test_mds_disconnected() {
408        let mut g = Graph::with_vertices(4);
409        g.add_edge(0, 1).unwrap();
410        g.add_edge(2, 3).unwrap();
411        let pos = layout_mds(&g, None).unwrap();
412        assert_eq!(pos.len(), 4);
413        for p in &pos {
414            assert!(p[0].is_finite() && p[1].is_finite());
415        }
416        // Components should be separated
417        let comp0_max_x = pos[0][0].max(pos[1][0]);
418        let comp1_min_x = pos[2][0].min(pos[3][0]);
419        assert!(comp1_min_x > comp0_max_x - 0.1 || comp0_max_x > comp1_min_x - 0.1);
420    }
421
422    #[test]
423    fn test_mds_deterministic() {
424        let mut g = Graph::with_vertices(4);
425        g.add_edge(0, 1).unwrap();
426        g.add_edge(1, 2).unwrap();
427        g.add_edge(2, 3).unwrap();
428        g.add_edge(3, 0).unwrap();
429        let pos1 = layout_mds(&g, None).unwrap();
430        let pos2 = layout_mds(&g, None).unwrap();
431        for i in 0..4 {
432            assert!((pos1[i][0] - pos2[i][0]).abs() < 1e-8);
433            assert!((pos1[i][1] - pos2[i][1]).abs() < 1e-8);
434        }
435    }
436
437    #[test]
438    fn test_mds_distance_preservation() {
439        // For a path graph, MDS should roughly preserve relative distances
440        let mut g = Graph::with_vertices(4);
441        g.add_edge(0, 1).unwrap();
442        g.add_edge(1, 2).unwrap();
443        g.add_edge(2, 3).unwrap();
444        let pos = layout_mds(&g, None).unwrap();
445
446        // Distance 0-1 should be less than distance 0-3
447        let d01 = ((pos[0][0] - pos[1][0]).powi(2) + (pos[0][1] - pos[1][1]).powi(2)).sqrt();
448        let d03 = ((pos[0][0] - pos[3][0]).powi(2) + (pos[0][1] - pos[3][1]).powi(2)).sqrt();
449        assert!(d01 < d03, "d01={d01} should be < d03={d03}");
450    }
451
452    #[test]
453    fn test_mds_complete_graph() {
454        let mut g = Graph::with_vertices(4);
455        for i in 0..4u32 {
456            for j in (i + 1)..4 {
457                g.add_edge(i, j).unwrap();
458            }
459        }
460        let pos = layout_mds(&g, None).unwrap();
461        assert_eq!(pos.len(), 4);
462        for p in &pos {
463            assert!(p[0].is_finite() && p[1].is_finite());
464        }
465    }
466}