Skip to main content

leiden_rs/
resolution.rs

1//! Resolution profile analysis for the Leiden algorithm.
2//!
3//! Runs community detection at multiple resolution (gamma) values to find
4//! community structures at different scales. Two methods are provided:
5//!
6//! - **Linear scan** ([`resolution_scan`]): evaluates evenly spaced gamma values.
7//! - **Bisection scan** ([`resolution_profile`]): efficiently finds only the gamma
8//!   values where the partition changes, using recursive bisection.
9
10use crate::graph::GraphData;
11use crate::leiden::{Leiden, LeidenConfig, LeidenOutput, QualityType};
12use crate::partition::Partition;
13#[cfg(feature = "rayon")]
14use rayon::prelude::*;
15
16/// A single entry in a resolution profile, recording the result at one gamma value.
17#[derive(Debug, Clone)]
18#[non_exhaustive]
19pub struct ResolutionEntry {
20    /// The resolution parameter (gamma) used.
21    pub resolution: f64,
22    /// Number of communities found.
23    pub num_communities: usize,
24    /// Quality score of the partition at this resolution.
25    pub quality: f64,
26    /// The community partition.
27    pub partition: Partition,
28}
29
30/// Run the Leiden algorithm at evenly spaced gamma values.
31///
32/// For each of `num_points` values linearly spaced from `resolution_range.0`
33/// to `resolution_range.1`, runs Leiden and collects the result.
34/// Sequential seeds are derived from the base `seed` so that each point is
35/// reproducible but independent.
36pub fn resolution_scan(
37    data: &GraphData,
38    quality: QualityType,
39    resolution_range: (f64, f64),
40    num_points: usize,
41    seed: Option<u64>,
42) -> crate::error::Result<Vec<ResolutionEntry>> {
43    if num_points == 0 {
44        return Ok(Vec::new());
45    }
46
47    let compute_entry = |i: usize| -> crate::error::Result<ResolutionEntry> {
48        let gamma = if num_points == 1 {
49            resolution_range.0
50        } else {
51            resolution_range.0
52                + (resolution_range.1 - resolution_range.0) * (i as f64) / ((num_points - 1) as f64)
53        };
54
55        let config = LeidenConfig {
56            resolution: gamma,
57            quality,
58            seed: seed.map(|s| s + i as u64),
59            ..Default::default()
60        };
61
62        let LeidenOutput {
63            partition,
64            quality: q,
65            ..
66        } = Leiden::new(config).run(data)?;
67
68        Ok(ResolutionEntry {
69            resolution: gamma,
70            num_communities: partition.num_communities(),
71            quality: q,
72            partition,
73        })
74    };
75
76    #[cfg(feature = "rayon")]
77    let entries: Vec<ResolutionEntry> = (0..num_points)
78        .into_par_iter()
79        .map(compute_entry)
80        .collect::<crate::error::Result<Vec<_>>>()?;
81
82    #[cfg(not(feature = "rayon"))]
83    let entries: Vec<ResolutionEntry> = (0..num_points)
84        .map(compute_entry)
85        .collect::<crate::error::Result<Vec<_>>>()?;
86
87    Ok(entries)
88}
89
90/// Run the Leiden algorithm at adaptively chosen gamma values using bisection.
91///
92/// Similar to leidenalg's `Optimiser.resolution_profile()`, this method finds
93/// only the gamma values where the partition changes, avoiding unnecessary
94/// evaluations at gamma values that produce identical partitions.
95///
96/// The bisection stops when the resolution range is narrower than
97/// `min_diff_resolution` or the partitions are identical. Entries with the
98/// same number of communities and quality within `min_diff_quality` are
99/// deduplicated.
100pub fn resolution_profile(
101    data: &GraphData,
102    quality: QualityType,
103    resolution_range: (f64, f64),
104    seed: Option<u64>,
105    min_diff_resolution: f64,
106    min_diff_quality: f64,
107) -> crate::error::Result<Vec<ResolutionEntry>> {
108    let mut entries = Vec::new();
109    let ctx = BisectContext {
110        data,
111        quality,
112        min_diff_resolution,
113        max_depth: 50,
114    };
115    bisect(&ctx, resolution_range.0, resolution_range.1, seed.unwrap_or(0), 0, &mut entries)?;
116
117    entries.sort_by(|a, b| {
118        a.resolution
119            .partial_cmp(&b.resolution)
120            .unwrap_or(std::cmp::Ordering::Equal)
121    });
122
123    let mut deduped: Vec<ResolutionEntry> = Vec::new();
124    for entry in entries {
125        if let Some(last) = deduped.last() {
126            if last.num_communities == entry.num_communities
127                && (last.quality - entry.quality).abs() < min_diff_quality
128            {
129                continue;
130            }
131        }
132        deduped.push(entry);
133    }
134
135    Ok(deduped)
136}
137
138struct BisectContext<'a> {
139    data: &'a GraphData,
140    quality: QualityType,
141    min_diff_resolution: f64,
142    max_depth: usize,
143}
144
145fn bisect(
146    ctx: &BisectContext<'_>,
147    gamma_low: f64,
148    gamma_high: f64,
149    seed: u64,
150    depth: usize,
151    entries: &mut Vec<ResolutionEntry>,
152) -> crate::error::Result<()> {
153    let n = ctx.data.node_count();
154    if n == 0 {
155        let config = LeidenConfig {
156            resolution: gamma_low,
157            quality: ctx.quality,
158            seed: Some(seed),
159            ..Default::default()
160        };
161        let LeidenOutput {
162            partition,
163            quality: q,
164            ..
165        } = Leiden::new(config).run(ctx.data)?;
166        entries.push(ResolutionEntry {
167            resolution: gamma_low,
168            num_communities: partition.num_communities(),
169            quality: q,
170            partition,
171        });
172        return Ok(());
173    }
174
175    if depth >= ctx.max_depth {
176        let config_low = LeidenConfig {
177            resolution: gamma_low,
178            quality: ctx.quality,
179            seed: Some(seed),
180            ..Default::default()
181        };
182        let LeidenOutput {
183            partition: p_low,
184            quality: q_low,
185            ..
186        } = Leiden::new(config_low).run(ctx.data)?;
187        let config_high = LeidenConfig {
188            resolution: gamma_high,
189            quality: ctx.quality,
190            seed: Some(seed.wrapping_add(1)),
191            ..Default::default()
192        };
193        let LeidenOutput {
194            partition: p_high,
195            quality: q_high,
196            ..
197        } = Leiden::new(config_high).run(ctx.data)?;
198        let partitions_differ = !partitions_equal(&p_low, &p_high, n);
199        entries.push(ResolutionEntry {
200            resolution: gamma_low,
201            num_communities: p_low.num_communities(),
202            quality: q_low,
203            partition: p_low,
204        });
205        if partitions_differ {
206            entries.push(ResolutionEntry {
207                resolution: gamma_high,
208                num_communities: p_high.num_communities(),
209                quality: q_high,
210                partition: p_high,
211            });
212        }
213        return Ok(());
214    }
215
216    let config_low = LeidenConfig {
217        resolution: gamma_low,
218        quality: ctx.quality,
219        seed: Some(seed),
220        ..Default::default()
221    };
222    let LeidenOutput {
223        partition: p_low,
224        quality: q_low,
225        ..
226    } = Leiden::new(config_low).run(ctx.data)?;
227    
228    let config_high = LeidenConfig {
229        resolution: gamma_high,
230        quality: ctx.quality,
231        seed: Some(seed.wrapping_add(1)),
232        ..Default::default()
233    };
234    let LeidenOutput {
235        partition: p_high,
236        quality: q_high,
237        ..
238    } = Leiden::new(config_high).run(ctx.data)?;
239
240    let range = gamma_high - gamma_low;
241
242    if !partitions_equal(&p_low, &p_high, n) && range > ctx.min_diff_resolution {
243        let mid = if gamma_low > 0.0 && gamma_high > 0.0 {
244            (gamma_low * gamma_high).sqrt()
245        } else {
246            (gamma_low + gamma_high) / 2.0
247        };
248
249        bisect(
250            ctx,
251            gamma_low,
252            mid,
253            seed.wrapping_add(2),
254            depth + 1,
255            entries,
256        )?;
257        bisect(
258            ctx,
259            mid,
260            gamma_high,
261            seed.wrapping_add(3),
262            depth + 1,
263            entries,
264        )?;
265    } else {
266        let partitions_differ = !partitions_equal(&p_low, &p_high, n);
267        entries.push(ResolutionEntry {
268            resolution: gamma_low,
269            num_communities: p_low.num_communities(),
270            quality: q_low,
271            partition: p_low,
272        });
273        if partitions_differ {
274            // Range is too small to recurse further, but the partition
275            // changed — record the gamma_high endpoint too.
276            entries.push(ResolutionEntry {
277                resolution: gamma_high,
278                num_communities: p_high.num_communities(),
279                quality: q_high,
280                partition: p_high,
281            });
282        }
283    }
284
285    Ok(())
286}
287
288/// Compare two partitions by normalizing their membership vectors.
289///
290/// Two partitions are equal if every node is in the same group relative to
291/// all other nodes, regardless of the specific community IDs assigned.
292fn partitions_equal(p1: &Partition, p2: &Partition, n: usize) -> bool {
293    if p1.num_communities() != p2.num_communities() {
294        return false;
295    }
296
297    let norm1 = normalize_partition(p1, n);
298    let norm2 = normalize_partition(p2, n);
299    norm1 == norm2
300}
301
302/// Normalize a partition's membership vector so that community IDs are
303/// assigned in order of first appearance (lowest node index gets ID 0, etc.).
304fn normalize_partition(partition: &Partition, n: usize) -> Vec<usize> {
305    let mut mapping: Vec<usize> = vec![usize::MAX; n];
306    let mut next_id = 0usize;
307    let mut result = vec![0usize; n];
308
309    for (node, &comm) in partition.as_slice().iter().enumerate() {
310        if mapping[comm] == usize::MAX {
311            mapping[comm] = next_id;
312            next_id += 1;
313        }
314        result[node] = mapping[comm];
315    }
316
317    result
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::graph::GraphDataBuilder;
324
325    fn make_two_cliques() -> GraphData {
326        let mut b = GraphDataBuilder::new(10);
327        // Clique 1: nodes 0-4
328        for i in 0..5 {
329            for j in (i + 1)..5 {
330                b.add_edge(i, j, 1.0).unwrap();
331            }
332        }
333        // Clique 2: nodes 5-9
334        for i in 5..10 {
335            for j in (i + 1)..10 {
336                b.add_edge(i, j, 1.0).unwrap();
337            }
338        }
339        // Single bridge edge between cliques
340        b.add_edge(0, 5, 1.0).unwrap();
341        b.build().unwrap()
342    }
343
344    #[test]
345    fn test_resolution_scan_two_cliques() {
346        let data = make_two_cliques();
347
348        let entries =
349            resolution_scan(&data, QualityType::Modularity, (0.1, 2.0), 10, Some(42)).unwrap();
350
351        assert_eq!(entries.len(), 10);
352
353        // Entries should be ordered by resolution
354        for i in 1..entries.len() {
355            assert!(entries[i].resolution > entries[i - 1].resolution);
356        }
357
358        // At low gamma (0.1), modularity should favor merging into 1 or 2 communities
359        assert!(
360            entries[0].num_communities >= 1,
361            "expected at least 1 community at low gamma, got {}",
362            entries[0].num_communities,
363        );
364
365        // At some gamma values, the two-cliques structure (2 communities) should appear
366        let has_two = entries.iter().any(|e| e.num_communities == 2);
367        assert!(has_two, "expected at least one entry with 2 communities");
368
369        // All partitions should have 10 nodes
370        for entry in &entries {
371            assert_eq!(entry.partition.len(), 10);
372        }
373    }
374
375    #[test]
376    fn test_resolution_profile_two_cliques() {
377        let data = make_two_cliques();
378
379        let entries =
380            resolution_profile(&data, QualityType::CPM, (0.1, 2.0), Some(42), 0.01, 0.001).unwrap();
381
382        assert!(
383            !entries.is_empty(),
384            "profile should produce at least one entry"
385        );
386
387        // Entries should be sorted by resolution
388        for i in 1..entries.len() {
389            assert!(
390                entries[i].resolution >= entries[i - 1].resolution,
391                "entries not sorted: {} >= {}",
392                entries[i].resolution,
393                entries[i - 1].resolution,
394            );
395        }
396
397        // All resolutions should be within range
398        for entry in &entries {
399            assert!(entry.resolution >= 0.1 - 1e-10);
400            assert!(entry.resolution <= 2.0 + 1e-10);
401        }
402
403        // All partitions should have 10 nodes
404        for entry in &entries {
405            assert_eq!(entry.partition.len(), 10);
406        }
407    }
408
409    #[test]
410    fn test_resolution_scan_single_point() {
411        let data = make_two_cliques();
412
413        let entries =
414            resolution_scan(&data, QualityType::Modularity, (1.0, 2.0), 1, Some(42)).unwrap();
415
416        assert_eq!(entries.len(), 1);
417        assert!((entries[0].resolution - 1.0).abs() < 1e-10);
418    }
419
420    #[test]
421    fn test_resolution_scan_zero_points() {
422        let data = make_two_cliques();
423
424        let entries =
425            resolution_scan(&data, QualityType::Modularity, (0.1, 2.0), 0, Some(42)).unwrap();
426
427        assert!(entries.is_empty());
428    }
429
430    #[test]
431    fn test_partitions_equal_identical() {
432        let p1 = Partition::from_membership(vec![0, 0, 1, 1]);
433        let p2 = Partition::from_membership(vec![0, 0, 1, 1]);
434        assert!(partitions_equal(&p1, &p2, 4));
435    }
436
437    #[test]
438    fn test_partitions_equal_relabel() {
439        let p1 = Partition::from_membership(vec![0, 0, 1, 1]);
440        let p2 = Partition::from_membership(vec![1, 1, 0, 0]);
441        assert!(partitions_equal(&p1, &p2, 4));
442    }
443
444    #[test]
445    fn test_partitions_equal_different() {
446        let p1 = Partition::from_membership(vec![0, 0, 0, 1]);
447        let p2 = Partition::from_membership(vec![0, 0, 1, 1]);
448        assert!(!partitions_equal(&p1, &p2, 4));
449    }
450
451    #[test]
452    fn test_bisect_records_both_endpoints() {
453        let mut b = GraphDataBuilder::new(2);
454        b.add_edge(0, 1, 0.1).unwrap();
455        let g = b.build().unwrap();
456        let profile = resolution_profile(
457            &g,
458            QualityType::Modularity,
459            (0.1, 2.0),
460            Some(42),
461            1e-2,
462            1e-6,
463        )
464        .unwrap();
465        assert!(
466            profile.len() >= 2,
467            "expected at least 2 entries, got {}",
468            profile.len()
469        );
470        let eps = 1e-6;
471        assert!(
472            (profile.first().unwrap().resolution - 0.1).abs() < eps,
473            "first resolution should be 0.1, got {}",
474            profile.first().unwrap().resolution
475        );
476        assert!(
477            (profile.last().unwrap().resolution - 2.0).abs() < eps,
478            "last resolution should be 2.0, got {}",
479            profile.last().unwrap().resolution
480        );
481    }
482
483    #[test]
484    fn test_resolution_profile_deduplication() {
485        // With a very wide quality tolerance, entries should be deduplicated
486        let data = make_two_cliques();
487
488        let entries = resolution_profile(
489            &data,
490            QualityType::CPM,
491            (0.5, 1.5),
492            Some(42),
493            0.01,
494            1000.0, // very large tolerance — everything deduplicates
495        )
496        .unwrap();
497
498        // Should produce at most 1 entry after deduplication if all have same num_communities
499        assert!(
500            entries.len() <= 3,
501            "expected heavy deduplication, got {} entries",
502            entries.len(),
503        );
504    }
505}