Skip to main content

scirs2_graph/community/
infomap.rs

1//! Infomap community detection algorithm (Rosvall & Bergstrom 2008).
2//!
3//! Infomap uses the **map equation** to find the partition of a network that
4//! minimises the expected description length of a random walk trajectory.
5//!
6//! The map equation is:
7//!
8//! ```text
9//! L(M) = q_↷ · H(Q) + Σ_i  p_i^↺ · H(P_i)
10//! ```
11//!
12//! where:
13//! - `q_↷` is the probability of exiting a module in one step,
14//! - `H(Q)` is the entropy of the module-transition process,
15//! - `p_i^↺` is the fraction of time spent inside module `i`,
16//! - `H(P_i)` is the entropy of within-module movements.
17//!
18//! ## Implementation
19//! This implementation uses a greedy optimisation with multiple random restarts
20//! (`n_trials`). In each trial:
21//! 1. Initialise with a random partition.
22//! 2. Iteratively move nodes to neighbour modules if the map equation decreases.
23//! 3. Compact community IDs and evaluate the final code length.
24//!
25//! The trial with the lowest map equation (best compression) is returned.
26//!
27//! ## Reference
28//! Rosvall, M., & Bergstrom, C. T. (2008). Maps of random walks on complex
29//! networks reveal community structure. *Proceedings of the National Academy
30//! of Sciences*, 105(4), 1118–1123.
31
32use std::collections::HashMap;
33
34use scirs2_core::random::{Rng, RngExt, SeedableRng, StdRng};
35
36use super::louvain::compact_communities;
37use crate::error::{GraphError, Result};
38
39// ─────────────────────────────────────────────────────────────────────────────
40// Configuration
41// ─────────────────────────────────────────────────────────────────────────────
42
43/// Configuration for the Infomap algorithm.
44#[derive(Debug, Clone)]
45pub struct InfomapConfig {
46    /// Number of independent restarts (best result is returned).
47    pub n_trials: usize,
48    /// Maximum iterations per trial.
49    pub max_iter: usize,
50    /// Convergence tolerance on the map equation.
51    pub tol: f64,
52}
53
54impl Default for InfomapConfig {
55    fn default() -> Self {
56        Self {
57            n_trials: 10,
58            max_iter: 200,
59            tol: 1e-6,
60        }
61    }
62}
63
64// ─────────────────────────────────────────────────────────────────────────────
65// Internal structures
66// ─────────────────────────────────────────────────────────────────────────────
67
68struct SparseAdj {
69    adj: Vec<Vec<(usize, f64)>>,
70    degree: Vec<f64>,
71    two_m: f64,
72}
73
74impl SparseAdj {
75    fn from_edge_list(edges: &[(usize, usize, f64)], n: usize) -> Self {
76        let mut adj: Vec<Vec<(usize, f64)>> = vec![vec![]; n];
77        let mut degree = vec![0.0f64; n];
78        let mut two_m = 0.0f64;
79
80        for &(u, v, w) in edges {
81            if u >= n || v >= n {
82                continue;
83            }
84            adj[u].push((v, w));
85            if u != v {
86                adj[v].push((u, w));
87            }
88            degree[u] += w;
89            if u != v {
90                degree[v] += w;
91            }
92            two_m += 2.0 * w;
93        }
94        Self { adj, degree, two_m }
95    }
96
97    /// Compute stationary distribution (proportional to degree for undirected graphs).
98    fn stationary(&self) -> Vec<f64> {
99        let total = self.two_m;
100        if total == 0.0 {
101            let n = self.adj.len();
102            return vec![1.0 / n.max(1) as f64; n];
103        }
104        self.degree.iter().map(|&d| d / total).collect()
105    }
106}
107
108// ─────────────────────────────────────────────────────────────────────────────
109// Map equation
110// ─────────────────────────────────────────────────────────────────────────────
111
112/// Compute the map equation code length for a given partition.
113///
114/// L(M) = q_↷ · H(Q) + Σ_i p_i^↺ · H(P_i)
115fn map_equation(g: &SparseAdj, assignments: &[usize]) -> f64 {
116    let n = g.adj.len();
117    if n == 0 || g.two_m == 0.0 {
118        return 0.0;
119    }
120
121    let pi = g.stationary(); // stationary distribution
122    let n_comms = assignments.iter().max().copied().unwrap_or(0) + 1;
123
124    // For each module i: visit rate, exit rate
125    let mut module_visit: Vec<f64> = vec![0.0; n_comms];
126    let mut module_exit: Vec<f64> = vec![0.0; n_comms];
127
128    for node in 0..n {
129        let c = assignments[node];
130        if c >= n_comms {
131            continue;
132        }
133        module_visit[c] += pi[node];
134        // Exit rate: fraction of random walk steps leaving module c from node
135        let mut exit_w = 0.0f64;
136        let mut total_w = 0.0f64;
137        for &(nbr, w) in &g.adj[node] {
138            total_w += w;
139            if nbr < assignments.len() && assignments[nbr] != c {
140                exit_w += w;
141            }
142        }
143        if total_w > 0.0 {
144            module_exit[c] += pi[node] * exit_w / total_w;
145        }
146    }
147
148    let q_total: f64 = module_exit.iter().sum();
149
150    // H(Q): entropy of module exit process
151    let h_q = if q_total > 0.0 {
152        -module_exit
153            .iter()
154            .filter(|&&q| q > 0.0)
155            .map(|&q| {
156                let p = q / q_total;
157                p * p.ln()
158            })
159            .sum::<f64>()
160    } else {
161        0.0
162    };
163
164    // H(P_i): within-module entropy for each module i
165    let mut h_modules = 0.0f64;
166    for i in 0..n_comms {
167        let p_stay = module_visit[i] - module_exit[i];
168        let p_total = module_visit[i] + module_exit[i]; // total flow through module
169        if p_total <= 0.0 {
170            continue;
171        }
172        // Within-module distribution: node visits + self-exit
173        let mut within: Vec<f64> = Vec::new();
174        for node in 0..n {
175            if assignments[node] == i {
176                within.push(pi[node]);
177            }
178        }
179        within.push(module_exit[i]); // "exit codeword"
180
181        let entropy: f64 = within
182            .iter()
183            .filter(|&&v| v > 0.0)
184            .map(|&v| {
185                let frac = v / p_total;
186                if frac > 0.0 {
187                    -frac * frac.ln()
188                } else {
189                    0.0
190                }
191            })
192            .sum();
193        h_modules += (p_total) * entropy;
194        let _ = p_stay;
195    }
196
197    q_total * h_q + h_modules
198}
199
200// ─────────────────────────────────────────────────────────────────────────────
201// Main entry point
202// ─────────────────────────────────────────────────────────────────────────────
203
204/// Run the Infomap community detection algorithm.
205///
206/// # Arguments
207/// * `adj`    – Weighted edge list `(src, dst, weight)`.
208/// * `n_nodes` – Total number of nodes.
209/// * `config` – Algorithm configuration (`n_trials`, `max_iter`, `tol`).
210///
211/// # Returns
212/// Community assignment vector (0-indexed, densely numbered).
213pub fn infomap(
214    adj: &[(usize, usize, f64)],
215    n_nodes: usize,
216    config: &InfomapConfig,
217) -> Result<Vec<usize>> {
218    if n_nodes == 0 {
219        return Err(GraphError::InvalidGraph(
220            "infomap: n_nodes must be > 0".into(),
221        ));
222    }
223
224    let g = SparseAdj::from_edge_list(adj, n_nodes);
225    if g.two_m == 0.0 {
226        // Isolated graph: each node is its own community
227        return Ok((0..n_nodes).collect());
228    }
229
230    let mut best_assignments: Option<Vec<usize>> = None;
231    let mut best_code_length = f64::INFINITY;
232
233    for trial in 0..config.n_trials.max(1) {
234        let seed = 0xabcdef01_u64.wrapping_add(trial as u64 * 0x9e3779b9);
235        let result = infomap_trial(&g, config, seed)?;
236        let code_len = map_equation(&g, &result);
237        if code_len < best_code_length {
238            best_code_length = code_len;
239            best_assignments = Some(result);
240        }
241    }
242
243    let mut assignments = best_assignments
244        .ok_or_else(|| GraphError::AlgorithmError("infomap: no trials completed".into()))?;
245    compact_communities(&mut assignments);
246    Ok(assignments)
247}
248
249// ─────────────────────────────────────────────────────────────────────────────
250// Single trial
251// ─────────────────────────────────────────────────────────────────────────────
252
253fn infomap_trial(g: &SparseAdj, config: &InfomapConfig, seed: u64) -> Result<Vec<usize>> {
254    let n = g.adj.len();
255    let mut rng = StdRng::seed_from_u64(seed);
256
257    // Initialise with random partition into ceil(sqrt(n)) modules
258    let init_comms = ((n as f64).sqrt().ceil() as usize).max(1).min(n);
259    let mut assignments: Vec<usize> = (0..n).map(|_| rng.random_range(0..init_comms)).collect();
260
261    let mut prev_code_len = map_equation(g, &assignments);
262
263    for _iter in 0..config.max_iter {
264        let improved = infomap_move_phase(g, &mut assignments, &mut rng);
265        if !improved {
266            break;
267        }
268        compact_communities(&mut assignments);
269
270        let code_len = map_equation(g, &assignments);
271        if (prev_code_len - code_len).abs() < config.tol {
272            break;
273        }
274        prev_code_len = code_len;
275    }
276
277    Ok(assignments)
278}
279
280// ─────────────────────────────────────────────────────────────────────────────
281// Move phase
282// ─────────────────────────────────────────────────────────────────────────────
283
284/// Greedy node-move phase: for each node, try moving to each neighbour's module.
285/// Accept the move that most reduces the map equation.
286fn infomap_move_phase(g: &SparseAdj, assignments: &mut [usize], rng: &mut impl Rng) -> bool {
287    let n = g.adj.len();
288    let mut improved = false;
289
290    // Randomised order
291    let mut order: Vec<usize> = (0..n).collect();
292    for i in (1..n).rev() {
293        let j = rng.random_range(0..=i);
294        order.swap(i, j);
295    }
296
297    let current_code = map_equation(g, assignments);
298    let mut running_code = current_code;
299
300    for &node in &order {
301        let orig_comm = assignments[node];
302
303        // Candidate modules: current module + all neighbour modules
304        let mut candidate_comms: Vec<usize> = vec![orig_comm];
305        for &(nbr, _) in &g.adj[node] {
306            let c = assignments[nbr];
307            if !candidate_comms.contains(&c) {
308                candidate_comms.push(c);
309            }
310        }
311
312        let mut best_comm = orig_comm;
313        let mut best_code = running_code;
314
315        for c in candidate_comms {
316            if c == orig_comm {
317                continue;
318            }
319            assignments[node] = c;
320            let new_code = map_equation(g, assignments);
321            if new_code < best_code {
322                best_code = new_code;
323                best_comm = c;
324            }
325        }
326
327        assignments[node] = best_comm;
328        if best_comm != orig_comm {
329            running_code = best_code;
330            improved = true;
331        }
332    }
333    improved
334}
335
336// ─────────────────────────────────────────────────────────────────────────────
337// Tests
338// ─────────────────────────────────────────────────────────────────────────────
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn two_clique_edges(k: usize) -> (Vec<(usize, usize, f64)>, usize) {
345        let n = 2 * k;
346        let mut edges = Vec::new();
347        for i in 0..k {
348            for j in (i + 1)..k {
349                edges.push((i, j, 1.0));
350                edges.push((k + i, k + j, 1.0));
351            }
352        }
353        edges.push((0, k, 0.05));
354        (edges, n)
355    }
356
357    #[test]
358    fn test_infomap_two_cliques() {
359        let (edges, n) = two_clique_edges(4);
360        let config = InfomapConfig {
361            n_trials: 5,
362            max_iter: 50,
363            tol: 1e-6,
364        };
365        let labels = infomap(&edges, n, &config).expect("infomap");
366        assert_eq!(labels.len(), 8);
367        // Clique 1
368        let l0 = labels[0];
369        for i in 1..4 {
370            assert_eq!(labels[i], l0, "clique1 node {} wrong", i);
371        }
372        // Clique 2
373        let l1 = labels[4];
374        for i in 5..8 {
375            assert_eq!(labels[i], l1, "clique2 node {} wrong", i);
376        }
377        assert_ne!(l0, l1, "different communities expected");
378    }
379
380    #[test]
381    fn test_infomap_empty_error() {
382        let config = InfomapConfig::default();
383        assert!(infomap(&[], 0, &config).is_err());
384    }
385
386    #[test]
387    fn test_infomap_no_edges() {
388        let config = InfomapConfig {
389            n_trials: 1,
390            max_iter: 10,
391            tol: 1e-6,
392        };
393        let labels = infomap(&[], 4, &config).expect("infomap no edges");
394        // Each isolated node in its own community
395        assert_eq!(labels.len(), 4);
396        let unique: std::collections::HashSet<usize> = labels.iter().cloned().collect();
397        assert_eq!(unique.len(), 4);
398    }
399
400    #[test]
401    fn test_map_equation_perfect_partition() {
402        let (edges, n) = two_clique_edges(3);
403        let g = SparseAdj::from_edge_list(&edges, n);
404        let perfect: Vec<usize> = (0..6).map(|i| if i < 3 { 0 } else { 1 }).collect();
405        let single: Vec<usize> = vec![0; 6];
406        let code_perfect = map_equation(&g, &perfect);
407        let code_single = map_equation(&g, &single);
408        // Two-community partition should have shorter (lower) code length
409        assert!(
410            code_perfect <= code_single + 1e-9,
411            "perfect partition code={code_perfect}, single={code_single}"
412        );
413    }
414
415    #[test]
416    fn test_default_config() {
417        let cfg = InfomapConfig::default();
418        assert_eq!(cfg.n_trials, 10);
419        assert_eq!(cfg.max_iter, 200);
420        assert!(cfg.tol < 1e-5);
421    }
422}