Skip to main content

rust_igraph/algorithms/properties/
degree_deviation.rs

1//! Degree deviation measures (ALGO-TR-084).
2//!
3//! Robust deviation and dispersion measures for the degree sequence,
4//! complementing the variance-based `bell_index` / `degree_cv`:
5//!
6//! - **Mean absolute deviation** `MAD(G) = (1/n) Σ_v |d(v) − d̄|`
7//! - **Median absolute deviation** `MedAD(G) = median_v |d(v) − median(d)|`
8//! - **Degree entropy (nat)** `H(G) = −Σ_k p(k) ln p(k)` (natural-log)
9//! - **Normalized degree entropy** `H_norm(G) = H(G) / ln(n)` → [0, 1]
10
11#![allow(
12    clippy::cast_possible_truncation,
13    clippy::cast_precision_loss,
14    clippy::many_single_char_names,
15    clippy::needless_range_loop,
16    clippy::too_many_lines
17)]
18
19use crate::core::{Graph, IgraphResult};
20use std::collections::HashMap;
21
22/// Compute the mean absolute deviation of the degree sequence.
23///
24/// `MAD(G) = (1/n) Σ_v |d(v) - d̄|`
25///
26/// A robust dispersion measure less sensitive to outliers than
27/// variance. Returns 0.0 for empty or edgeless graphs.
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, degree_mad};
33///
34/// // K_3: all degrees equal → MAD = 0
35/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
36/// assert!(degree_mad(&g).unwrap().abs() < 1e-10);
37/// ```
38pub fn degree_mad(graph: &Graph) -> IgraphResult<f64> {
39    let n = graph.vcount() as usize;
40    if n == 0 {
41        return Ok(0.0);
42    }
43
44    let degrees = collect_degrees(graph)?;
45    let mean = degrees.iter().sum::<f64>() / n as f64;
46
47    let sum_abs: f64 = degrees.iter().map(|&d| (d - mean).abs()).sum();
48    Ok(sum_abs / n as f64)
49}
50
51/// Compute the median absolute deviation of the degree sequence.
52///
53/// `MedAD(G) = median_v |d(v) - median(d)|`
54///
55/// The most robust dispersion measure — the median of absolute
56/// deviations from the median. Returns 0.0 for empty graphs.
57///
58/// # Examples
59///
60/// ```
61/// use rust_igraph::{Graph, degree_median_ad};
62///
63/// // K_3: all degrees equal → MedAD = 0
64/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
65/// assert!(degree_median_ad(&g).unwrap().abs() < 1e-10);
66/// ```
67pub fn degree_median_ad(graph: &Graph) -> IgraphResult<f64> {
68    let n = graph.vcount() as usize;
69    if n == 0 {
70        return Ok(0.0);
71    }
72
73    let degrees = collect_degrees(graph)?;
74    let med = median_of(&degrees);
75
76    let mut abs_devs: Vec<f64> = degrees.iter().map(|&d| (d - med).abs()).collect();
77    abs_devs.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
78
79    Ok(median_sorted(&abs_devs))
80}
81
82/// Compute the Shannon entropy of the degree distribution (natural log).
83///
84/// `H(G) = -Σ_k p(k) ln(p(k))`
85///
86/// where `p(k) = n_k / n` is the fraction of vertices with degree k.
87/// Returns 0.0 for empty graphs or when all vertices have the same
88/// degree. Uses the natural logarithm (ln), complementing the
89/// base-2 `degree_entropy` in `graph_entropy.rs`.
90///
91/// # Examples
92///
93/// ```
94/// use rust_igraph::{Graph, degree_entropy_ln};
95///
96/// // K_3: all degree 2 → single class → H = 0
97/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
98/// assert!(degree_entropy_ln(&g).unwrap().abs() < 1e-10);
99/// ```
100pub fn degree_entropy_ln(graph: &Graph) -> IgraphResult<f64> {
101    let n = graph.vcount() as usize;
102    if n == 0 {
103        return Ok(0.0);
104    }
105
106    let mut counts: HashMap<usize, usize> = HashMap::new();
107    for v in 0..n {
108        let d = graph.degree(v as u32)?;
109        *counts.entry(d).or_insert(0) += 1;
110    }
111
112    let nf = n as f64;
113    let mut entropy = 0.0_f64;
114    for &count in counts.values() {
115        if count > 0 {
116            let p = count as f64 / nf;
117            entropy -= p * p.ln();
118        }
119    }
120
121    Ok(entropy)
122}
123
124/// Compute the normalized degree entropy.
125///
126/// `H_norm(G) = H(G) / ln(n)`
127///
128/// Normalizes the natural-log degree entropy to [0, 1]. Returns 0.0
129/// for graphs with fewer than 2 vertices. A value of 1.0 indicates
130/// maximum diversity (all vertices have distinct degrees).
131///
132/// # Examples
133///
134/// ```
135/// use rust_igraph::{Graph, degree_entropy_normalized};
136///
137/// // K_4: all degrees equal → H_norm = 0
138/// let g = Graph::from_edges(
139///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4),
140/// ).unwrap();
141/// assert!(degree_entropy_normalized(&g).unwrap().abs() < 1e-10);
142/// ```
143pub fn degree_entropy_normalized(graph: &Graph) -> IgraphResult<f64> {
144    let n = graph.vcount() as usize;
145    if n < 2 {
146        return Ok(0.0);
147    }
148
149    let h = degree_entropy_ln(graph)?;
150    let h_max = (n as f64).ln();
151
152    if h_max <= 0.0 {
153        return Ok(0.0);
154    }
155
156    Ok(h / h_max)
157}
158
159fn collect_degrees(graph: &Graph) -> IgraphResult<Vec<f64>> {
160    let n = graph.vcount() as usize;
161    let mut degrees = Vec::with_capacity(n);
162    for v in 0..n {
163        degrees.push(graph.degree(v as u32)? as f64);
164    }
165    Ok(degrees)
166}
167
168fn median_of(vals: &[f64]) -> f64 {
169    let mut sorted = vals.to_vec();
170    sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
171    median_sorted(&sorted)
172}
173
174fn median_sorted(sorted: &[f64]) -> f64 {
175    let n = sorted.len();
176    if n == 0 {
177        return 0.0;
178    }
179    if n % 2 == 1 {
180        sorted[n / 2]
181    } else {
182        f64::midpoint(sorted[n / 2 - 1], sorted[n / 2])
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn single_edge() -> Graph {
191        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
192    }
193
194    fn path3() -> Graph {
195        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
196    }
197
198    fn k3() -> Graph {
199        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
200    }
201
202    fn k4() -> Graph {
203        Graph::from_edges(
204            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
205            false,
206            Some(4),
207        )
208        .unwrap()
209    }
210
211    fn cycle4() -> Graph {
212        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
213    }
214
215    fn star5() -> Graph {
216        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
217    }
218
219    fn paw() -> Graph {
220        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
221    }
222
223    // --- degree_mad ---
224
225    #[test]
226    fn mad_empty() {
227        let g = Graph::with_vertices(0);
228        assert!(degree_mad(&g).unwrap().abs() < 1e-10);
229    }
230
231    #[test]
232    fn mad_isolated() {
233        let g = Graph::with_vertices(5);
234        assert!(degree_mad(&g).unwrap().abs() < 1e-10);
235    }
236
237    #[test]
238    fn mad_regular_zero() {
239        assert!(degree_mad(&k3()).unwrap().abs() < 1e-10);
240        assert!(degree_mad(&k4()).unwrap().abs() < 1e-10);
241        assert!(degree_mad(&cycle4()).unwrap().abs() < 1e-10);
242    }
243
244    #[test]
245    fn mad_single_edge() {
246        assert!(degree_mad(&single_edge()).unwrap().abs() < 1e-10);
247    }
248
249    #[test]
250    fn mad_star5() {
251        // degrees: [4,1,1,1,1], mean=1.6
252        // |d-mean|: [2.4,0.6,0.6,0.6,0.6]
253        // MAD = (2.4+4·0.6)/5 = 4.8/5 = 0.96
254        assert!((degree_mad(&star5()).unwrap() - 0.96).abs() < 1e-10);
255    }
256
257    #[test]
258    fn mad_path3() {
259        // degrees: [1,2,1], mean=4/3
260        // |d-mean|: [1/3,2/3,1/3]
261        // MAD = (4/3)/3 = 4/9
262        let expected = 4.0 / 9.0;
263        assert!((degree_mad(&path3()).unwrap() - expected).abs() < 1e-10);
264    }
265
266    #[test]
267    fn mad_paw() {
268        // degrees: [2,2,3,1], mean=2
269        // |d-mean|: [0,0,1,1]
270        // MAD = 2/4 = 0.5
271        assert!((degree_mad(&paw()).unwrap() - 0.5).abs() < 1e-10);
272    }
273
274    #[test]
275    fn mad_nonneg() {
276        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
277            assert!(degree_mad(g).unwrap() >= -1e-10);
278        }
279    }
280
281    // --- degree_median_ad ---
282
283    #[test]
284    fn medad_empty() {
285        let g = Graph::with_vertices(0);
286        assert!(degree_median_ad(&g).unwrap().abs() < 1e-10);
287    }
288
289    #[test]
290    fn medad_isolated() {
291        let g = Graph::with_vertices(5);
292        assert!(degree_median_ad(&g).unwrap().abs() < 1e-10);
293    }
294
295    #[test]
296    fn medad_regular_zero() {
297        assert!(degree_median_ad(&k3()).unwrap().abs() < 1e-10);
298        assert!(degree_median_ad(&k4()).unwrap().abs() < 1e-10);
299        assert!(degree_median_ad(&cycle4()).unwrap().abs() < 1e-10);
300    }
301
302    #[test]
303    fn medad_single_edge() {
304        assert!(degree_median_ad(&single_edge()).unwrap().abs() < 1e-10);
305    }
306
307    #[test]
308    fn medad_star5() {
309        // degrees: [4,1,1,1,1], median=1
310        // |d-median|: [3,0,0,0,0], sorted: [0,0,0,0,3]
311        // median of abs devs = 0
312        assert!(degree_median_ad(&star5()).unwrap().abs() < 1e-10);
313    }
314
315    #[test]
316    fn medad_path3() {
317        // degrees: [1,2,1], median=1
318        // |d-median|: [0,1,0], sorted: [0,0,1]
319        // MedAD = 0
320        assert!(degree_median_ad(&path3()).unwrap().abs() < 1e-10);
321    }
322
323    #[test]
324    fn medad_paw() {
325        // degrees: [2,2,3,1], median=2
326        // |d-median|: [0,0,1,1], sorted: [0,0,1,1]
327        // MedAD = (0+1)/2 = 0.5
328        assert!((degree_median_ad(&paw()).unwrap() - 0.5).abs() < 1e-10);
329    }
330
331    #[test]
332    fn medad_nonneg() {
333        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
334            assert!(degree_median_ad(g).unwrap() >= -1e-10);
335        }
336    }
337
338    // --- degree_entropy_ln ---
339
340    #[test]
341    fn ent_empty() {
342        let g = Graph::with_vertices(0);
343        assert!(degree_entropy_ln(&g).unwrap().abs() < 1e-10);
344    }
345
346    #[test]
347    fn ent_isolated() {
348        // all degree 0 → single class → H=0
349        let g = Graph::with_vertices(5);
350        assert!(degree_entropy_ln(&g).unwrap().abs() < 1e-10);
351    }
352
353    #[test]
354    fn ent_regular_zero() {
355        assert!(degree_entropy_ln(&k3()).unwrap().abs() < 1e-10);
356        assert!(degree_entropy_ln(&k4()).unwrap().abs() < 1e-10);
357        assert!(degree_entropy_ln(&cycle4()).unwrap().abs() < 1e-10);
358    }
359
360    #[test]
361    fn ent_single_edge() {
362        // both degree 1 → H=0
363        assert!(degree_entropy_ln(&single_edge()).unwrap().abs() < 1e-10);
364    }
365
366    #[test]
367    fn ent_star5() {
368        // degrees: {4:1, 1:4}, p(4)=1/5, p(1)=4/5
369        // H = -(1/5)ln(1/5) - (4/5)ln(4/5)
370        let expected = -(0.2_f64 * 0.2_f64.ln() + 0.8 * 0.8_f64.ln());
371        assert!((degree_entropy_ln(&star5()).unwrap() - expected).abs() < 1e-10);
372    }
373
374    #[test]
375    fn ent_path3() {
376        // degrees: {1:2, 2:1}, p(1)=2/3, p(2)=1/3
377        // H = -(2/3)ln(2/3) - (1/3)ln(1/3)
378        let p1: f64 = 2.0 / 3.0;
379        let p2: f64 = 1.0 / 3.0;
380        let expected = -(p1 * p1.ln() + p2 * p2.ln());
381        assert!((degree_entropy_ln(&path3()).unwrap() - expected).abs() < 1e-10);
382    }
383
384    #[test]
385    fn ent_paw() {
386        // degrees: {2:2, 3:1, 1:1}, p(2)=1/2, p(3)=1/4, p(1)=1/4
387        // H = -(1/2)ln(1/2) - (1/4)ln(1/4) - (1/4)ln(1/4)
388        let expected = -(0.5_f64 * 0.5_f64.ln() + 2.0 * 0.25 * 0.25_f64.ln());
389        assert!((degree_entropy_ln(&paw()).unwrap() - expected).abs() < 1e-10);
390    }
391
392    #[test]
393    fn ent_nonneg() {
394        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
395            assert!(degree_entropy_ln(g).unwrap() >= -1e-10);
396        }
397    }
398
399    // --- degree_entropy_normalized ---
400
401    #[test]
402    fn entnorm_empty() {
403        let g = Graph::with_vertices(0);
404        assert!(degree_entropy_normalized(&g).unwrap().abs() < 1e-10);
405    }
406
407    #[test]
408    fn entnorm_single() {
409        let g = Graph::with_vertices(1);
410        assert!(degree_entropy_normalized(&g).unwrap().abs() < 1e-10);
411    }
412
413    #[test]
414    fn entnorm_regular_zero() {
415        assert!(degree_entropy_normalized(&k3()).unwrap().abs() < 1e-10);
416        assert!(degree_entropy_normalized(&k4()).unwrap().abs() < 1e-10);
417        assert!(degree_entropy_normalized(&cycle4()).unwrap().abs() < 1e-10);
418    }
419
420    #[test]
421    fn entnorm_star5() {
422        let h = degree_entropy_ln(&star5()).unwrap();
423        let h_max = 5.0_f64.ln();
424        let expected = h / h_max;
425        assert!((degree_entropy_normalized(&star5()).unwrap() - expected).abs() < 1e-10);
426    }
427
428    #[test]
429    fn entnorm_paw() {
430        let h = degree_entropy_ln(&paw()).unwrap();
431        let h_max = 4.0_f64.ln();
432        let expected = h / h_max;
433        assert!((degree_entropy_normalized(&paw()).unwrap() - expected).abs() < 1e-10);
434    }
435
436    #[test]
437    fn entnorm_in_01() {
438        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
439            let val = degree_entropy_normalized(g).unwrap();
440            assert!(val >= -1e-10);
441            assert!(val <= 1.0 + 1e-10);
442        }
443    }
444
445    // --- cross-consistency ---
446
447    #[test]
448    fn mad_le_maxdev() {
449        // MAD ≤ max deviation always
450        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
451            let mad_val = degree_mad(g).unwrap();
452            let n = g.vcount() as usize;
453            let mut degrees = Vec::with_capacity(n);
454            for v in 0..n {
455                degrees.push(g.degree(v as u32).unwrap() as f64);
456            }
457            let mean = degrees.iter().sum::<f64>() / n as f64;
458            let max_dev: f64 = degrees
459                .iter()
460                .map(|&d| (d - mean).abs())
461                .fold(0.0, f64::max);
462            assert!(mad_val <= max_dev + 1e-10);
463        }
464    }
465
466    #[test]
467    fn medad_le_mad() {
468        // MedAD ≤ MAD for well-behaved distributions (not always, but for our test cases)
469        for g in &[k3(), k4(), cycle4()] {
470            assert!(degree_median_ad(g).unwrap() <= degree_mad(g).unwrap() + 1e-10);
471        }
472    }
473}