Skip to main content

rust_igraph/algorithms/properties/
clustering_profile.rs

1//! Clustering profile indices (ALGO-TR-124).
2//!
3//! Three novel topological ratio indices derived from the distribution of
4//! local clustering coefficients across vertices:
5//!
6//! - [`clustering_variance`]: variance of local clustering coefficients
7//!   (measures heterogeneity of local triangle density).
8//! - [`clustering_entropy`]: Shannon entropy of the binned clustering
9//!   coefficient distribution, normalised to \[0, 1\].
10//! - [`clustering_bimodality`]: Sarle's bimodality coefficient of the
11//!   clustering coefficient distribution (values > 5/9 suggest bimodality).
12
13use crate::algorithms::properties::triangles::transitivity_local_undirected;
14use crate::core::{Graph, IgraphResult};
15
16/// Variance of local clustering coefficients.
17///
18/// Computes the population variance of the local clustering coefficients
19/// across all vertices with degree ≥ 2 (vertices with degree < 2 have
20/// undefined clustering coefficient and are excluded).
21///
22/// Returns 0.0 if fewer than 2 vertices have defined clustering coefficients.
23///
24/// # Examples
25///
26/// ```
27/// use rust_igraph::{Graph, clustering_variance};
28///
29/// // K4: all local CC = 1.0 → variance = 0
30/// let g = Graph::from_edges(
31///     &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
32///     false,
33///     Some(4),
34/// )
35/// .unwrap();
36/// assert!(clustering_variance(&g).unwrap().abs() < 1e-10);
37/// ```
38pub fn clustering_variance(graph: &Graph) -> IgraphResult<f64> {
39    let ccs = defined_clustering_coefficients(graph)?;
40    if ccs.len() < 2 {
41        return Ok(0.0);
42    }
43    #[allow(clippy::cast_precision_loss)]
44    let n = ccs.len() as f64;
45    let mean = ccs.iter().sum::<f64>() / n;
46    let var = ccs.iter().map(|&c| (c - mean).powi(2)).sum::<f64>() / n;
47    Ok(var)
48}
49
50/// Normalised Shannon entropy of the clustering coefficient distribution.
51///
52/// Bins the local clustering coefficients into 10 equal-width bins on
53/// \[0, 1\] and computes the Shannon entropy of the resulting histogram,
54/// normalised by `ln(num_non_empty_bins)` so the result is in \[0, 1\].
55///
56/// Returns 0.0 if fewer than 2 vertices have defined clustering coefficients
57/// or if all values fall in a single bin.
58///
59/// # Examples
60///
61/// ```
62/// use rust_igraph::{Graph, clustering_entropy};
63///
64/// // K4: all CC = 1.0, single bin → entropy = 0
65/// let g = Graph::from_edges(
66///     &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
67///     false,
68///     Some(4),
69/// )
70/// .unwrap();
71/// assert!(clustering_entropy(&g).unwrap().abs() < 1e-10);
72/// ```
73pub fn clustering_entropy(graph: &Graph) -> IgraphResult<f64> {
74    const NUM_BINS: usize = 10;
75
76    let ccs = defined_clustering_coefficients(graph)?;
77    if ccs.len() < 2 {
78        return Ok(0.0);
79    }
80
81    // Bin into 10 bins: [0,0.1), [0.1,0.2), ..., [0.9,1.0]
82    let mut bins = [0u32; NUM_BINS];
83    for &c in &ccs {
84        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
85        let idx = if c >= 1.0 {
86            NUM_BINS - 1
87        } else {
88            #[allow(clippy::cast_precision_loss)]
89            let scaled = c * (NUM_BINS as f64);
90            scaled as usize
91        };
92        bins[idx] += 1;
93    }
94
95    let non_empty: Vec<u32> = bins.iter().copied().filter(|&b| b > 0).collect();
96    let num_non_empty = non_empty.len();
97    if num_non_empty <= 1 {
98        return Ok(0.0);
99    }
100
101    #[allow(clippy::cast_precision_loss)]
102    let total = ccs.len() as f64;
103    let mut entropy = 0.0_f64;
104    for &count in &non_empty {
105        let p = f64::from(count) / total;
106        entropy -= p * p.ln();
107    }
108
109    #[allow(clippy::cast_precision_loss)]
110    let max_entropy = (num_non_empty as f64).ln();
111    Ok(entropy / max_entropy)
112}
113
114/// Sarle's bimodality coefficient of the clustering coefficient distribution.
115///
116/// Defined as `(skewness² + 1) / kurtosis` where kurtosis is the excess
117/// kurtosis + 3 (i.e. the raw kurtosis). Values > 5/9 ≈ 0.556 suggest
118/// a bimodal or uniform distribution; values near 1/3 suggest unimodal.
119///
120/// Returns 0.0 if fewer than 4 vertices have defined clustering coefficients.
121///
122/// # Examples
123///
124/// ```
125/// use rust_igraph::{Graph, clustering_bimodality};
126///
127/// // K4: all CC = 1.0, zero variance → bimodality not meaningful, returns 0
128/// let g = Graph::from_edges(
129///     &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
130///     false,
131///     Some(4),
132/// )
133/// .unwrap();
134/// assert!(clustering_bimodality(&g).unwrap().abs() < 1e-10);
135/// ```
136pub fn clustering_bimodality(graph: &Graph) -> IgraphResult<f64> {
137    let ccs = defined_clustering_coefficients(graph)?;
138    if ccs.len() < 4 {
139        return Ok(0.0);
140    }
141    #[allow(clippy::cast_precision_loss)]
142    let n = ccs.len() as f64;
143    let mean = ccs.iter().sum::<f64>() / n;
144    let m2 = ccs.iter().map(|&c| (c - mean).powi(2)).sum::<f64>() / n;
145    if m2 < 1e-15 {
146        return Ok(0.0);
147    }
148    let m3 = ccs.iter().map(|&c| (c - mean).powi(3)).sum::<f64>() / n;
149    let m4 = ccs.iter().map(|&c| (c - mean).powi(4)).sum::<f64>() / n;
150
151    let skewness = m3 / m2.powf(1.5);
152    let kurtosis = m4 / (m2 * m2); // raw kurtosis (not excess)
153
154    if kurtosis < 1e-15 {
155        return Ok(0.0);
156    }
157
158    Ok((skewness * skewness + 1.0) / kurtosis)
159}
160
161/// Extract defined (non-None) local clustering coefficients.
162fn defined_clustering_coefficients(graph: &Graph) -> IgraphResult<Vec<f64>> {
163    let local_cc = transitivity_local_undirected(graph)?;
164    Ok(local_cc.into_iter().flatten().collect())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    // --- clustering_variance ---
172
173    #[test]
174    fn variance_empty() {
175        let g = Graph::with_vertices(0);
176        assert!(clustering_variance(&g).unwrap().abs() < 1e-12);
177    }
178
179    #[test]
180    fn variance_edgeless() {
181        let g = Graph::with_vertices(5);
182        assert!(clustering_variance(&g).unwrap().abs() < 1e-12);
183    }
184
185    #[test]
186    fn variance_complete() {
187        // K4: all CC = 1.0 → variance = 0
188        let g = Graph::from_edges(
189            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
190            false,
191            Some(4),
192        )
193        .unwrap();
194        assert!(clustering_variance(&g).unwrap().abs() < 1e-10);
195    }
196
197    #[test]
198    fn variance_mixed() {
199        // Triangle + pendant: vertex 2 has CC=1/3, vertices 0,1 have CC=1.0
200        // (vertex 3 has degree 1, excluded)
201        // mean = (1 + 1 + 1/3) / 3 = 7/9
202        // var = ((1-7/9)^2 + (1-7/9)^2 + (1/3-7/9)^2) / 3
203        let g = Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap();
204        let v = clustering_variance(&g).unwrap();
205        assert!(
206            v > 0.0,
207            "Mixed graph should have positive variance, got {v}"
208        );
209    }
210
211    // --- clustering_entropy ---
212
213    #[test]
214    fn entropy_empty() {
215        let g = Graph::with_vertices(0);
216        assert!(clustering_entropy(&g).unwrap().abs() < 1e-12);
217    }
218
219    #[test]
220    fn entropy_complete() {
221        // K4: all CC = 1.0, single bin → entropy = 0
222        let g = Graph::from_edges(
223            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
224            false,
225            Some(4),
226        )
227        .unwrap();
228        assert!(clustering_entropy(&g).unwrap().abs() < 1e-10);
229    }
230
231    #[test]
232    fn entropy_mixed() {
233        // Graph with varied CC values → positive entropy
234        let g =
235            Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
236        let h = clustering_entropy(&g).unwrap();
237        assert!(h >= 0.0, "Entropy should be >= 0, got {h}");
238        assert!(h <= 1.0, "Entropy should be <= 1, got {h}");
239    }
240
241    // --- clustering_bimodality ---
242
243    #[test]
244    fn bimodality_empty() {
245        let g = Graph::with_vertices(0);
246        assert!(clustering_bimodality(&g).unwrap().abs() < 1e-12);
247    }
248
249    #[test]
250    fn bimodality_complete() {
251        // K4: all CC = 1.0, zero variance → 0
252        let g = Graph::from_edges(
253            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
254            false,
255            Some(4),
256        )
257        .unwrap();
258        assert!(clustering_bimodality(&g).unwrap().abs() < 1e-10);
259    }
260
261    #[test]
262    fn bimodality_positive() {
263        // Larger graph with varied CC
264        let g = Graph::from_edges(
265            &[
266                (0, 1),
267                (1, 2),
268                (0, 2),
269                (2, 3),
270                (3, 4),
271                (4, 5),
272                (3, 5),
273                (5, 6),
274                (6, 7),
275                (5, 7),
276            ],
277            false,
278            Some(8),
279        )
280        .unwrap();
281        let b = clustering_bimodality(&g).unwrap();
282        assert!(b > 0.0, "Should have positive bimodality, got {b}");
283    }
284}