Skip to main content

helix_graph_algorithms/algorithms/
louvain.rs

1use std::collections::BTreeMap;
2use std::num::NonZeroUsize;
3
4use serde::{Deserialize, Serialize};
5
6use crate::{Graph, GraphError, NodeId, NonNegativeFiniteF64, PositiveFiniteF64};
7
8/// Proper weighted multi-level Louvain options.
9#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
10pub struct LouvainOptions {
11    /// Positive finite modularity resolution.
12    pub resolution: PositiveFiniteF64,
13    /// Non-negative finite minimum modularity improvement.
14    pub threshold: NonNegativeFiniteF64,
15    /// Deterministic node-visit seed.
16    pub seed: u64,
17    /// Maximum aggregation levels.
18    pub max_levels: NonZeroUsize,
19}
20
21impl Default for LouvainOptions {
22    fn default() -> Self {
23        Self {
24            resolution: PositiveFiniteF64::new(1.0).expect("one is positive"),
25            threshold: NonNegativeFiniteF64::new(1e-4).expect("threshold is non-negative"),
26            seed: 42,
27            max_levels: NonZeroUsize::new(10).expect("10 is non-zero"),
28        }
29    }
30}
31
32/// One canonical community.
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
34pub struct Community {
35    /// Stable ID equal to the lexicographically smallest member.
36    pub id: NodeId,
37    /// Members in deterministic ID order.
38    pub node_ids: Vec<NodeId>,
39}
40
41/// Louvain partition and diagnostics.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct CommunityResult {
44    /// Canonically ordered communities.
45    pub communities: Vec<Community>,
46    /// Final weighted modularity.
47    pub modularity: f64,
48    /// Number of local-move/aggregation levels executed.
49    pub levels: usize,
50}
51
52#[derive(Debug, Clone, Copy)]
53pub(super) struct LevelEdge {
54    pub(super) source: usize,
55    pub(super) target: usize,
56    pub(super) weight: f64,
57}
58
59pub(super) struct LevelGraph {
60    pub(super) node_members: Vec<Vec<usize>>,
61    pub(super) edges: Vec<LevelEdge>,
62    pub(super) adjacency: Vec<Vec<(usize, f64)>>,
63    pub(super) degrees: Vec<f64>,
64    pub(super) total_weight: f64,
65}
66
67impl LevelGraph {
68    pub(super) fn from_original(graph: &Graph) -> Self {
69        let edges = graph
70            .edges()
71            .iter()
72            .map(|edge| LevelEdge {
73                source: graph
74                    .node_index(&edge.source)
75                    .expect("validated edge source exists"),
76                target: graph
77                    .node_index(&edge.target)
78                    .expect("validated edge target exists"),
79                weight: edge.weight.unwrap_or(1.0),
80            })
81            .collect::<Vec<_>>();
82        Self::new(
83            (0..graph.node_count()).map(|node| vec![node]).collect(),
84            edges,
85        )
86    }
87
88    pub(super) fn new(node_members: Vec<Vec<usize>>, edges: Vec<LevelEdge>) -> Self {
89        let mut adjacency = vec![Vec::new(); node_members.len()];
90        let mut degrees = vec![0.0; node_members.len()];
91        let mut total_weight = 0.0;
92        for edge in &edges {
93            total_weight += edge.weight;
94            if edge.source == edge.target {
95                degrees[edge.source] += edge.weight * 2.0;
96            } else {
97                degrees[edge.source] += edge.weight;
98                degrees[edge.target] += edge.weight;
99                adjacency[edge.source].push((edge.target, edge.weight));
100                adjacency[edge.target].push((edge.source, edge.weight));
101            }
102        }
103        for row in &mut adjacency {
104            row.sort_by_key(|(neighbor, _)| *neighbor);
105        }
106        Self {
107            node_members,
108            edges,
109            adjacency,
110            degrees,
111            total_weight,
112        }
113    }
114
115    fn one_level(&self, resolution: f64, seed: &mut u64) -> Vec<usize> {
116        self.local_move(
117            &(0..self.node_members.len()).collect::<Vec<_>>(),
118            resolution,
119            seed,
120            usize::MAX,
121        )
122        .0
123    }
124
125    pub(super) fn local_move(
126        &self,
127        initial: &[usize],
128        resolution: f64,
129        seed: &mut u64,
130        max_iterations: usize,
131    ) -> (Vec<usize>, bool) {
132        let node_count = self.node_members.len();
133        assert_eq!(initial.len(), node_count, "one community per level node");
134        let mut communities = compress_communities(initial);
135        if self.total_weight == 0.0 {
136            return (communities, false);
137        }
138        let mut community_degrees = vec![0.0; node_count];
139        for (node, community) in communities.iter().enumerate() {
140            community_degrees[*community] += self.degrees[node];
141        }
142        let mut order = (0..node_count).collect::<Vec<_>>();
143        shuffle(&mut order, seed);
144        let mut moved_any = false;
145        for _ in 0..max_iterations {
146            let mut moved = false;
147            for node in &order {
148                let old_community = communities[*node];
149                let degree = self.degrees[*node];
150                let mut weights_by_community = BTreeMap::<usize, f64>::new();
151                for (neighbor, weight) in &self.adjacency[*node] {
152                    *weights_by_community
153                        .entry(communities[*neighbor])
154                        .or_default() += *weight;
155                }
156                community_degrees[old_community] -= degree;
157                let denominator = 2.0 * self.total_weight * self.total_weight;
158                let remove_cost = -weights_by_community
159                    .get(&old_community)
160                    .copied()
161                    .unwrap_or(0.0)
162                    / self.total_weight
163                    + resolution * community_degrees[old_community] * degree / denominator;
164                let mut best_community = old_community;
165                let mut best_gain = 0.0;
166                for (candidate, edge_weight) in weights_by_community {
167                    let gain = remove_cost + edge_weight / self.total_weight
168                        - resolution * community_degrees[candidate] * degree / denominator;
169                    if gain > best_gain
170                        || (gain == best_gain && gain > 0.0 && candidate < best_community)
171                    {
172                        best_gain = gain;
173                        best_community = candidate;
174                    }
175                }
176                community_degrees[best_community] += degree;
177                if best_community != old_community {
178                    communities[*node] = best_community;
179                    moved = true;
180                    moved_any = true;
181                }
182            }
183            if !moved {
184                break;
185            }
186        }
187        (compress_communities(&communities), moved_any)
188    }
189
190    pub(super) fn aggregate(&self, communities: &[usize]) -> Self {
191        let community_count = communities.iter().max().map_or(0, |max| max + 1);
192        let mut members = vec![Vec::new(); community_count];
193        for (node, community) in communities.iter().enumerate() {
194            members[*community].extend(self.node_members[node].iter().copied());
195        }
196        let mut edge_weights = BTreeMap::<(usize, usize), f64>::new();
197        for edge in &self.edges {
198            let source = communities[edge.source];
199            let target = communities[edge.target];
200            let pair = if source <= target {
201                (source, target)
202            } else {
203                (target, source)
204            };
205            *edge_weights.entry(pair).or_default() += edge.weight;
206        }
207        Self::new(
208            members,
209            edge_weights
210                .into_iter()
211                .map(|((source, target), weight)| LevelEdge {
212                    source,
213                    target,
214                    weight,
215                })
216                .collect(),
217        )
218    }
219}
220
221impl Graph {
222    /// Compute a deterministic weighted multi-level Louvain partition.
223    pub fn louvain_communities(
224        &self,
225        options: LouvainOptions,
226    ) -> Result<CommunityResult, GraphError> {
227        validate_community_graph(self, "Louvain")?;
228        if self.node_count() == 0 {
229            return Ok(CommunityResult {
230                communities: Vec::new(),
231                modularity: 0.0,
232                levels: 0,
233            });
234        }
235
236        let original_edges = LevelGraph::from_original(self).edges;
237        let mut level = LevelGraph::from_original(self);
238        let mut seed = options.seed;
239        let mut final_assignment = (0..self.node_count()).collect::<Vec<_>>();
240        let mut previous_modularity = f64::NEG_INFINITY;
241        let mut final_modularity = 0.0;
242        let mut levels = 0;
243        for _ in 0..options.max_levels.get() {
244            let communities = level.one_level(options.resolution.get(), &mut seed);
245            levels += 1;
246            final_assignment =
247                assignment_for_original_nodes(self.node_count(), &level.node_members, &communities);
248            final_modularity = modularity(
249                self.node_count(),
250                &original_edges,
251                &final_assignment,
252                options.resolution.get(),
253            );
254            let community_count = communities.iter().max().map_or(0, |max| max + 1);
255            if community_count == level.node_members.len()
256                || (previous_modularity.is_finite()
257                    && final_modularity - previous_modularity <= options.threshold.get())
258            {
259                break;
260            }
261            previous_modularity = final_modularity;
262            level = level.aggregate(&communities);
263        }
264
265        Ok(CommunityResult {
266            communities: canonical_communities(self, &final_assignment),
267            modularity: final_modularity,
268            levels,
269        })
270    }
271}
272
273pub(super) fn assignment_for_original_nodes(
274    original_node_count: usize,
275    members: &[Vec<usize>],
276    communities: &[usize],
277) -> Vec<usize> {
278    let mut assignment = vec![0; original_node_count];
279    for (node, originals) in members.iter().enumerate() {
280        for original in originals {
281            assignment[*original] = communities[node];
282        }
283    }
284    assignment
285}
286
287pub(super) fn compress_communities(communities: &[usize]) -> Vec<usize> {
288    let mut remap = BTreeMap::new();
289    let mut next = 0;
290    communities
291        .iter()
292        .map(|community| {
293            *remap.entry(*community).or_insert_with(|| {
294                let current = next;
295                next += 1;
296                current
297            })
298        })
299        .collect()
300}
301
302pub(super) fn modularity(
303    node_count: usize,
304    edges: &[LevelEdge],
305    communities: &[usize],
306    resolution: f64,
307) -> f64 {
308    let total_weight = edges.iter().map(|edge| edge.weight).sum::<f64>();
309    if total_weight == 0.0 {
310        return 0.0;
311    }
312    let community_count = communities.iter().max().map_or(0, |max| max + 1);
313    let mut internal_weight = vec![0.0; community_count];
314    let mut degree = vec![0.0; node_count];
315    for edge in edges {
316        if edge.source == edge.target {
317            degree[edge.source] += edge.weight * 2.0;
318        } else {
319            degree[edge.source] += edge.weight;
320            degree[edge.target] += edge.weight;
321        }
322        if communities[edge.source] == communities[edge.target] {
323            internal_weight[communities[edge.source]] += edge.weight;
324        }
325    }
326    let mut community_degree = vec![0.0; community_count];
327    for (node, node_degree) in degree.into_iter().enumerate() {
328        community_degree[communities[node]] += node_degree;
329    }
330    (0..community_count)
331        .map(|community| {
332            internal_weight[community] / total_weight
333                - resolution * (community_degree[community] / (2.0 * total_weight)).powi(2)
334        })
335        .sum()
336}
337
338pub(super) fn next_random(seed: &mut u64) -> u64 {
339    *seed = seed
340        .wrapping_mul(6_364_136_223_846_793_005)
341        .wrapping_add(1_442_695_040_888_963_407);
342    *seed
343}
344
345pub(super) fn shuffle(values: &mut [usize], seed: &mut u64) {
346    for index in (1..values.len()).rev() {
347        let other = (next_random(seed) as usize) % (index + 1);
348        values.swap(index, other);
349    }
350}
351
352pub(super) fn validate_community_graph(graph: &Graph, algorithm: &str) -> Result<(), GraphError> {
353    if graph.is_directed() {
354        return Err(GraphError::InvalidOption(format!(
355            "{algorithm} requires an explicit undirected graph view"
356        )));
357    }
358    Ok(())
359}
360
361pub(super) fn canonical_communities(graph: &Graph, assignment: &[usize]) -> Vec<Community> {
362    let assignment = compress_communities(assignment);
363    let community_count = assignment.iter().max().map_or(0, |max| max + 1);
364    let mut members = vec![Vec::new(); community_count];
365    for (node, community) in assignment.into_iter().enumerate() {
366        members[community].push(graph.node_id(node).clone());
367    }
368    for community in &mut members {
369        community.sort();
370    }
371    let mut communities = members
372        .into_iter()
373        .map(|node_ids| Community {
374            id: node_ids[0].clone(),
375            node_ids,
376        })
377        .collect::<Vec<_>>();
378    communities.sort_by(|left, right| left.id.cmp(&right.id));
379    communities
380}
381
382#[cfg(test)]
383mod tests {
384    use approx::assert_abs_diff_eq;
385
386    use super::*;
387    use crate::{Edge, GraphKind, Node};
388
389    fn bridged_triangles() -> Graph {
390        Graph::new(
391            GraphKind::Graph,
392            ["a", "b", "c", "d", "e", "f"].into_iter().map(Node::new),
393            [
394                Edge::new("ab", "a", "b"),
395                Edge::new("bc", "b", "c"),
396                Edge::new("ca", "c", "a"),
397                Edge::new("de", "d", "e"),
398                Edge::new("ef", "e", "f"),
399                Edge::new("fd", "f", "d"),
400                Edge::new("cd", "c", "d").with_weight(0.1),
401            ],
402        )
403        .unwrap()
404    }
405
406    #[test]
407    fn louvain_finds_dense_groups_and_is_seed_deterministic() {
408        let graph = bridged_triangles();
409        let first = graph
410            .louvain_communities(LouvainOptions::default())
411            .unwrap();
412        let second = graph
413            .louvain_communities(LouvainOptions::default())
414            .unwrap();
415        assert_eq!(first, second);
416        assert_eq!(first.communities.len(), 2);
417        assert_eq!(first.communities[0].node_ids, ["a", "b", "c"]);
418        assert_eq!(first.communities[1].node_ids, ["d", "e", "f"]);
419        assert!(first.modularity > 0.0);
420        assert_abs_diff_eq!(first.modularity, 0.4836065573770493, epsilon = 1e-12);
421    }
422
423    #[test]
424    fn louvain_keeps_isolated_nodes_as_singletons() {
425        let graph = Graph::new(GraphKind::Graph, [Node::new("a"), Node::new("b")], []).unwrap();
426        let result = graph
427            .louvain_communities(LouvainOptions::default())
428            .unwrap();
429        assert_eq!(result.communities.len(), 2);
430        assert_eq!(result.modularity, 0.0);
431    }
432
433    #[test]
434    fn louvain_requires_undirected_graph_and_options_reject_invalid_values() {
435        let graph = Graph::new(GraphKind::DiGraph, [Node::new("a")], []).unwrap();
436        assert!(matches!(
437            graph.louvain_communities(LouvainOptions::default()),
438            Err(GraphError::InvalidOption(_))
439        ));
440        assert!(PositiveFiniteF64::new(0.0).is_err());
441        assert!(NonNegativeFiniteF64::new(-1.0).is_err());
442    }
443
444    #[test]
445    fn louvain_respects_resolution_threshold_level_cap_and_parallel_weights() {
446        let graph = bridged_triangles();
447        let high_resolution = graph
448            .louvain_communities(LouvainOptions {
449                resolution: PositiveFiniteF64::new(2.0).unwrap(),
450                ..LouvainOptions::default()
451            })
452            .unwrap();
453        assert_abs_diff_eq!(
454            high_resolution.modularity,
455            -0.016393442622950727,
456            epsilon = 1e-12
457        );
458        let capped = graph
459            .louvain_communities(LouvainOptions {
460                threshold: NonNegativeFiniteF64::new(1.0).unwrap(),
461                max_levels: NonZeroUsize::new(1).unwrap(),
462                ..LouvainOptions::default()
463            })
464            .unwrap();
465        assert_eq!(capped.levels, 1);
466
467        let parallel = Graph::new(
468            GraphKind::MultiGraph,
469            [Node::new("a"), Node::new("b"), Node::new("c")],
470            [
471                Edge::new("ab1", "a", "b").with_weight(2.0),
472                Edge::new("ab2", "a", "b").with_weight(3.0),
473                Edge::new("bc", "b", "c").with_weight(0.1),
474                Edge::new("aa", "a", "a").with_weight(0.5),
475            ],
476        )
477        .unwrap();
478        let result = parallel
479            .louvain_communities(LouvainOptions::default())
480            .unwrap();
481        assert_eq!(result.communities[0].node_ids, ["a", "b", "c"]);
482    }
483}