Skip to main content

rust_igraph/algorithms/properties/
centrality_diversity.rs

1//! Centrality diversity indices (ALGO-TR-121).
2//!
3//! Measures of how consistently different centrality measures rank vertices:
4//!
5//! - **Centrality entropy** — Shannon entropy of the normalized centrality
6//!   distribution, measuring how evenly importance is spread
7//! - **Centrality divergence** — Jensen-Shannon divergence between degree
8//!   centrality and betweenness centrality distributions, measuring how
9//!   differently these two perspectives rank vertices
10//! - **Rank correlation** — Spearman rank correlation between degree and
11//!   betweenness centrality, measuring monotonic agreement
12
13#![allow(
14    clippy::cast_lossless,
15    clippy::cast_possible_truncation,
16    clippy::cast_precision_loss,
17    clippy::many_single_char_names,
18    clippy::needless_range_loop,
19    clippy::similar_names,
20    clippy::too_many_lines
21)]
22
23use crate::core::{Graph, IgraphResult};
24
25/// Compute the Shannon entropy of the degree centrality distribution.
26///
27/// Normalizes degrees to a probability distribution and computes
28/// `H = -sum(p_i * ln(p_i))`. Higher values indicate more evenly
29/// distributed importance; lower values indicate concentration around
30/// a few hubs. Returns 0.0 for trivial or edgeless graphs.
31///
32/// The result is normalized by `ln(n)` to give a value in `[0, 1]`.
33///
34/// # Examples
35///
36/// ```
37/// use rust_igraph::{Graph, centrality_entropy};
38///
39/// // K_4: all degrees equal → maximum entropy = 1.0
40/// let g = Graph::from_edges(
41///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4)
42/// ).unwrap();
43/// let h = centrality_entropy(&g).unwrap();
44/// assert!((h - 1.0).abs() < 1e-10);
45/// ```
46pub fn centrality_entropy(graph: &Graph) -> IgraphResult<f64> {
47    let n = graph.vcount() as usize;
48    if n < 2 {
49        return Ok(0.0);
50    }
51
52    let mut degrees = Vec::with_capacity(n);
53    let mut sum = 0_u64;
54    for v in 0..n {
55        let d = graph.degree(v as u32)?;
56        degrees.push(d);
57        sum += d as u64;
58    }
59
60    if sum == 0 {
61        return Ok(0.0);
62    }
63
64    let sum_f = sum as f64;
65    let mut entropy = 0.0_f64;
66    for &d in &degrees {
67        if d > 0 {
68            let p = d as f64 / sum_f;
69            entropy -= p * p.ln();
70        }
71    }
72
73    // Normalize by ln(n) to get [0, 1]
74    let max_entropy = (n as f64).ln();
75    if max_entropy > 0.0 {
76        Ok(entropy / max_entropy)
77    } else {
78        Ok(0.0)
79    }
80}
81
82/// Compute the Jensen-Shannon divergence between degree centrality and
83/// betweenness centrality distributions.
84///
85/// Both centrality vectors are normalized to probability distributions,
86/// then JSD = (KL(P||M) + KL(Q||M)) / 2 where M = (P+Q)/2.
87/// Returns a value in `[0, ln(2)]` (or 0.0 for trivial graphs).
88/// Higher values indicate that degree and betweenness rank vertices
89/// very differently.
90///
91/// # Examples
92///
93/// ```
94/// use rust_igraph::{Graph, centrality_divergence};
95///
96/// // Star graph: hub has high degree AND high betweenness → low divergence
97/// let g = Graph::from_edges(
98///     &[(0,1),(0,2),(0,3),(0,4)], false, Some(5)
99/// ).unwrap();
100/// let jsd = centrality_divergence(&g).unwrap();
101/// assert!(jsd < 0.3); // relatively low divergence
102/// ```
103pub fn centrality_divergence(graph: &Graph) -> IgraphResult<f64> {
104    let n = graph.vcount() as usize;
105    if n < 3 {
106        return Ok(0.0);
107    }
108
109    // Degree centrality
110    let mut degrees = Vec::with_capacity(n);
111    let mut deg_sum = 0_u64;
112    for v in 0..n {
113        let d = graph.degree(v as u32)?;
114        degrees.push(d as f64);
115        deg_sum += d as u64;
116    }
117
118    if deg_sum == 0 {
119        return Ok(0.0);
120    }
121
122    // Betweenness centrality
123    let bc = crate::algorithms::properties::betweenness::betweenness(graph)?;
124
125    let bc_sum: f64 = bc.iter().sum();
126
127    // If betweenness is all zero (e.g., complete graph), divergence is 0
128    if bc_sum <= 0.0 {
129        return Ok(0.0);
130    }
131
132    // Normalize to probability distributions
133    let deg_sum_f = deg_sum as f64;
134    let p: Vec<f64> = degrees.iter().map(|&d| d / deg_sum_f).collect();
135    let q: Vec<f64> = bc.iter().map(|&b| b / bc_sum).collect();
136
137    // Jensen-Shannon divergence
138    let mut jsd = 0.0_f64;
139    for i in 0..n {
140        let m_i = f64::midpoint(p[i], q[i]);
141        if m_i > 0.0 {
142            if p[i] > 0.0 {
143                jsd += p[i] * (p[i] / m_i).ln();
144            }
145            if q[i] > 0.0 {
146                jsd += q[i] * (q[i] / m_i).ln();
147            }
148        }
149    }
150    jsd /= 2.0;
151
152    Ok(jsd)
153}
154
155/// Compute the Spearman rank correlation between degree centrality and
156/// betweenness centrality.
157///
158/// Returns a value in `[-1, 1]`. Values near 1 indicate that vertices
159/// with high degree also have high betweenness (consistent importance).
160/// Values near 0 indicate no monotonic relationship. Returns 0.0 for
161/// trivial graphs or graphs where one centrality is constant.
162///
163/// # Examples
164///
165/// ```
166/// use rust_igraph::{Graph, centrality_rank_correlation};
167///
168/// // Path graph: degree and betweenness are inversely related at endpoints
169/// let g = Graph::from_edges(
170///     &[(0,1),(1,2),(2,3),(3,4)], false, Some(5)
171/// ).unwrap();
172/// let rho = centrality_rank_correlation(&g).unwrap();
173/// // For a path, internal vertices have both higher degree and betweenness
174/// assert!(rho > 0.5);
175/// ```
176pub fn centrality_rank_correlation(graph: &Graph) -> IgraphResult<f64> {
177    let n = graph.vcount() as usize;
178    if n < 3 {
179        return Ok(0.0);
180    }
181
182    // Degree centrality
183    let mut degrees = Vec::with_capacity(n);
184    for v in 0..n {
185        degrees.push(graph.degree(v as u32)? as f64);
186    }
187
188    // Betweenness centrality
189    let bc = crate::algorithms::properties::betweenness::betweenness(graph)?;
190
191    // Compute ranks (average rank for ties)
192    let deg_ranks = compute_ranks(&degrees);
193    let bc_ranks = compute_ranks(&bc);
194
195    // Spearman correlation = Pearson correlation of ranks
196    Ok(pearson_correlation(&deg_ranks, &bc_ranks))
197}
198
199/// Compute average ranks with tie-breaking (average rank for tied values).
200fn compute_ranks(values: &[f64]) -> Vec<f64> {
201    let n = values.len();
202    let mut indexed: Vec<(usize, f64)> = values.iter().copied().enumerate().collect();
203    indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
204
205    let mut ranks = vec![0.0_f64; n];
206    let mut i = 0;
207    while i < n {
208        let mut j = i;
209        // Find all tied values
210        while j < n && (indexed[j].1 - indexed[i].1).abs() < 1e-12 {
211            j += 1;
212        }
213        // Average rank for the tied group (1-based): positions i..j → ranks (i+1)..=j
214        // Average = (i + 1 + j) / 2
215        let avg_rank = (i + 1 + j) as f64 / 2.0;
216        for k in i..j {
217            ranks[indexed[k].0] = avg_rank;
218        }
219        i = j;
220    }
221    ranks
222}
223
224/// Pearson correlation coefficient between two vectors.
225fn pearson_correlation(x: &[f64], y: &[f64]) -> f64 {
226    let n = x.len();
227    if n == 0 {
228        return 0.0;
229    }
230
231    let mean_x: f64 = x.iter().sum::<f64>() / n as f64;
232    let mean_y: f64 = y.iter().sum::<f64>() / n as f64;
233
234    let mut cov = 0.0_f64;
235    let mut var_x = 0.0_f64;
236    let mut var_y = 0.0_f64;
237
238    for i in 0..n {
239        let dx = x[i] - mean_x;
240        let dy = y[i] - mean_y;
241        cov += dx * dy;
242        var_x += dx * dx;
243        var_y += dy * dy;
244    }
245
246    let denom = (var_x * var_y).sqrt();
247    if denom < 1e-15 {
248        return 0.0;
249    }
250
251    cov / denom
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn entropy_empty_graph() {
260        let g = Graph::with_vertices(0);
261        assert!(centrality_entropy(&g).unwrap().abs() < 1e-12);
262    }
263
264    #[test]
265    fn entropy_single_vertex() {
266        let g = Graph::with_vertices(1);
267        assert!(centrality_entropy(&g).unwrap().abs() < 1e-12);
268    }
269
270    #[test]
271    fn entropy_regular_graph_is_one() {
272        // K_4: all degrees equal → max entropy
273        let g = Graph::from_edges(
274            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
275            false,
276            Some(4),
277        )
278        .unwrap();
279        let h = centrality_entropy(&g).unwrap();
280        assert!((h - 1.0).abs() < 1e-10, "K4 entropy = {h}, expected 1.0");
281    }
282
283    #[test]
284    fn entropy_star_is_low() {
285        // Star: one hub with high degree, leaves with degree 1
286        let g =
287            Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4), (0, 5)], false, Some(6)).unwrap();
288        let h = centrality_entropy(&g).unwrap();
289        // Not maximum entropy
290        assert!(h < 0.95, "Star entropy = {h}, should be < 0.95");
291        assert!(h > 0.0, "Star entropy should be positive");
292    }
293
294    #[test]
295    fn divergence_empty() {
296        let g = Graph::with_vertices(2);
297        assert!(centrality_divergence(&g).unwrap().abs() < 1e-12);
298    }
299
300    #[test]
301    fn divergence_complete_graph() {
302        // K_4: betweenness is all zero → divergence is 0
303        let g = Graph::from_edges(
304            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
305            false,
306            Some(4),
307        )
308        .unwrap();
309        let jsd = centrality_divergence(&g).unwrap();
310        assert!(jsd.abs() < 1e-12);
311    }
312
313    #[test]
314    fn divergence_path_graph() {
315        // Path: degree and betweenness differ
316        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
317        let jsd = centrality_divergence(&g).unwrap();
318        assert!(jsd > 0.0, "Path JSD should be positive, got {jsd}");
319        assert!(
320            jsd < std::f64::consts::LN_2,
321            "JSD should be < ln(2), got {jsd}"
322        );
323    }
324
325    #[test]
326    fn rank_correlation_empty() {
327        let g = Graph::with_vertices(2);
328        assert!(centrality_rank_correlation(&g).unwrap().abs() < 1e-12);
329    }
330
331    #[test]
332    fn rank_correlation_path() {
333        // Path: internal vertices have both higher degree and betweenness
334        let g = Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 4)], false, Some(5)).unwrap();
335        let rho = centrality_rank_correlation(&g).unwrap();
336        assert!(
337            rho > 0.5,
338            "Path rank correlation should be > 0.5, got {rho}"
339        );
340    }
341
342    #[test]
343    fn rank_correlation_star() {
344        // Star: hub has both max degree and max betweenness
345        let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap();
346        let rho = centrality_rank_correlation(&g).unwrap();
347        // All leaves have same degree and same betweenness → correlation should be high
348        assert!(
349            rho > 0.8,
350            "Star rank correlation should be > 0.8, got {rho}"
351        );
352    }
353
354    #[test]
355    fn rank_correlation_edgeless() {
356        // Edgeless: all degrees 0, all betweenness 0 → correlation 0
357        let g = Graph::with_vertices(5);
358        let rho = centrality_rank_correlation(&g).unwrap();
359        assert!(rho.abs() < 1e-12);
360    }
361
362    #[test]
363    fn compute_ranks_basic() {
364        let values = vec![3.0, 1.0, 2.0, 1.0];
365        let ranks = compute_ranks(&values);
366        // 1.0 appears twice → average rank (1+2)/2 = 1.5
367        assert!((ranks[0] - 4.0).abs() < 1e-10); // 3.0 → rank 4
368        assert!((ranks[1] - 1.5).abs() < 1e-10); // 1.0 → rank 1.5
369        assert!((ranks[2] - 3.0).abs() < 1e-10); // 2.0 → rank 3
370        assert!((ranks[3] - 1.5).abs() < 1e-10); // 1.0 → rank 1.5
371    }
372}