Skip to main content

rust_igraph/algorithms/properties/
degree_moments.rs

1//! Degree distribution moments (ALGO-TR-082).
2//!
3//! Higher-order statistical moments and inequality measures of the
4//! degree sequence, complementing `degree_cv` and `bell_index`:
5//!
6//! - **Degree skewness** `γ₁ = (1/n)Σ((d(v)-d̄)/σ)³`
7//! - **Degree kurtosis** (excess) `γ₂ = (1/n)Σ((d(v)-d̄)/σ)⁴ - 3`
8//! - **Degree Gini coefficient** — inequality of the degree sequence
9//! - **Degree max-deviation** `Δ_max = max_v |d(v) - d̄|`
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};
20
21/// Compute the skewness of the degree distribution.
22///
23/// `γ₁ = (1/n) Σ_v ((d(v) - d̄) / σ)³`
24///
25/// Measures asymmetry of the degree distribution. Positive skewness
26/// means a right tail (few high-degree hubs), negative means left tail.
27/// Returns 0.0 for graphs with fewer than 3 vertices or zero variance.
28///
29/// # Examples
30///
31/// ```
32/// use rust_igraph::{Graph, degree_skewness};
33///
34/// // K_3: all degrees equal → skewness = 0
35/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
36/// assert!(degree_skewness(&g).unwrap().abs() < 1e-10);
37/// ```
38pub fn degree_skewness(graph: &Graph) -> IgraphResult<f64> {
39    let n = graph.vcount() as usize;
40    if n < 3 {
41        return Ok(0.0);
42    }
43
44    let degrees = collect_degrees(graph)?;
45    let mean = mean_of(&degrees);
46    let variance = variance_of(&degrees, mean);
47
48    if variance <= 0.0 {
49        return Ok(0.0);
50    }
51
52    let sigma = variance.sqrt();
53    let mut m3 = 0.0_f64;
54    for &d in &degrees {
55        let z = (d - mean) / sigma;
56        m3 += z * z * z;
57    }
58
59    Ok(m3 / n as f64)
60}
61
62/// Compute the excess kurtosis of the degree distribution.
63///
64/// `γ₂ = (1/n) Σ_v ((d(v) - d̄) / σ)⁴ - 3`
65///
66/// Measures the "tailedness" of the degree distribution relative
67/// to a normal distribution (excess kurtosis = 0 for normal).
68/// Returns 0.0 for graphs with fewer than 3 vertices or zero variance.
69///
70/// # Examples
71///
72/// ```
73/// use rust_igraph::{Graph, degree_kurtosis};
74///
75/// // K_4: all degrees equal → kurtosis = -3 (minimal, degenerate)
76/// // Actually: zero variance → returns 0.0
77/// let g = Graph::from_edges(
78///     &[(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)], false, Some(4),
79/// ).unwrap();
80/// assert!(degree_kurtosis(&g).unwrap().abs() < 1e-10);
81/// ```
82pub fn degree_kurtosis(graph: &Graph) -> IgraphResult<f64> {
83    let n = graph.vcount() as usize;
84    if n < 3 {
85        return Ok(0.0);
86    }
87
88    let degrees = collect_degrees(graph)?;
89    let mean = mean_of(&degrees);
90    let variance = variance_of(&degrees, mean);
91
92    if variance <= 0.0 {
93        return Ok(0.0);
94    }
95
96    let sigma = variance.sqrt();
97    let mut m4 = 0.0_f64;
98    for &d in &degrees {
99        let z = (d - mean) / sigma;
100        let z2 = z * z;
101        m4 += z2 * z2;
102    }
103
104    Ok(m4 / n as f64 - 3.0)
105}
106
107/// Compute the Gini coefficient of the degree sequence.
108///
109/// `Gini = (Σ_i Σ_j |d_i - d_j|) / (2 n² d̄)`
110///
111/// Measures inequality: 0 = perfect equality (all degrees the same),
112/// approaching 1 = maximal inequality (star-like).
113/// Returns 0.0 for edgeless graphs (d̄ = 0) or single-vertex graphs.
114///
115/// # Examples
116///
117/// ```
118/// use rust_igraph::{Graph, degree_gini};
119///
120/// // K_3: all degrees = 2 → Gini = 0
121/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
122/// assert!(degree_gini(&g).unwrap().abs() < 1e-10);
123/// ```
124pub fn degree_gini(graph: &Graph) -> IgraphResult<f64> {
125    let n = graph.vcount() as usize;
126    if n < 2 {
127        return Ok(0.0);
128    }
129
130    let degrees = collect_degrees(graph)?;
131    let mean = mean_of(&degrees);
132
133    if mean <= 0.0 {
134        return Ok(0.0);
135    }
136
137    let mut sum_abs_diff = 0.0_f64;
138    for i in 0..n {
139        for j in 0..n {
140            sum_abs_diff += (degrees[i] - degrees[j]).abs();
141        }
142    }
143
144    let nf = n as f64;
145    Ok(sum_abs_diff / (2.0 * nf * nf * mean))
146}
147
148/// Compute the maximum degree deviation from the mean.
149///
150/// `Δ_max = max_v |d(v) - d̄|`
151///
152/// The largest absolute deviation of any vertex's degree from the
153/// mean degree. Returns 0.0 for empty or edgeless graphs.
154///
155/// # Examples
156///
157/// ```
158/// use rust_igraph::{Graph, degree_max_deviation};
159///
160/// // Star S_5: center d=4, leaves d=1, d̄=1.6 → max|4-1.6|=2.4
161/// let g = Graph::from_edges(&[(0,1),(0,2),(0,3),(0,4)], false, Some(5)).unwrap();
162/// assert!((degree_max_deviation(&g).unwrap() - 2.4).abs() < 1e-10);
163/// ```
164pub fn degree_max_deviation(graph: &Graph) -> IgraphResult<f64> {
165    let n = graph.vcount() as usize;
166    if n == 0 {
167        return Ok(0.0);
168    }
169
170    let degrees = collect_degrees(graph)?;
171    let mean = mean_of(&degrees);
172
173    let mut max_dev = 0.0_f64;
174    for &d in &degrees {
175        let dev = (d - mean).abs();
176        if dev > max_dev {
177            max_dev = dev;
178        }
179    }
180
181    Ok(max_dev)
182}
183
184fn collect_degrees(graph: &Graph) -> IgraphResult<Vec<f64>> {
185    let n = graph.vcount() as usize;
186    let mut degrees = Vec::with_capacity(n);
187    for v in 0..n {
188        degrees.push(graph.degree(v as u32)? as f64);
189    }
190    Ok(degrees)
191}
192
193fn mean_of(vals: &[f64]) -> f64 {
194    if vals.is_empty() {
195        return 0.0;
196    }
197    vals.iter().sum::<f64>() / vals.len() as f64
198}
199
200fn variance_of(vals: &[f64], mean: f64) -> f64 {
201    if vals.is_empty() {
202        return 0.0;
203    }
204    let n = vals.len() as f64;
205    vals.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / n
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn single_edge() -> Graph {
213        Graph::from_edges(&[(0, 1)], false, Some(2)).unwrap()
214    }
215
216    fn path3() -> Graph {
217        Graph::from_edges(&[(0, 1), (1, 2)], false, Some(3)).unwrap()
218    }
219
220    fn k3() -> Graph {
221        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
222    }
223
224    fn k4() -> Graph {
225        Graph::from_edges(
226            &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)],
227            false,
228            Some(4),
229        )
230        .unwrap()
231    }
232
233    fn cycle4() -> Graph {
234        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
235    }
236
237    fn star5() -> Graph {
238        Graph::from_edges(&[(0, 1), (0, 2), (0, 3), (0, 4)], false, Some(5)).unwrap()
239    }
240
241    fn paw() -> Graph {
242        Graph::from_edges(&[(0, 1), (1, 2), (0, 2), (2, 3)], false, Some(4)).unwrap()
243    }
244
245    // --- degree_skewness ---
246
247    #[test]
248    fn skew_empty() {
249        let g = Graph::with_vertices(0);
250        assert!(degree_skewness(&g).unwrap().abs() < 1e-10);
251    }
252
253    #[test]
254    fn skew_isolated() {
255        let g = Graph::with_vertices(5);
256        assert!(degree_skewness(&g).unwrap().abs() < 1e-10);
257    }
258
259    #[test]
260    fn skew_regular_zero() {
261        assert!(degree_skewness(&k3()).unwrap().abs() < 1e-10);
262        assert!(degree_skewness(&k4()).unwrap().abs() < 1e-10);
263        assert!(degree_skewness(&cycle4()).unwrap().abs() < 1e-10);
264    }
265
266    #[test]
267    fn skew_single_edge() {
268        assert!(degree_skewness(&single_edge()).unwrap().abs() < 1e-10);
269    }
270
271    #[test]
272    fn skew_star5_positive() {
273        // Star: one high-degree hub → positive skewness
274        assert!(degree_skewness(&star5()).unwrap() > 0.0);
275    }
276
277    #[test]
278    fn skew_path3() {
279        // degrees: [1,2,1], mean=4/3, σ²=(1-4/3)²+(2-4/3)²+(1-4/3)² / 3
280        // = (1/9+4/9+1/9)/3 = (6/9)/3 = 2/9
281        // σ = √(2/9)
282        // z_0 = (1-4/3)/√(2/9) = (-1/3)/(√2/3) = -1/√2
283        // z_1 = (2-4/3)/√(2/9) = (2/3)/(√2/3) = 2/√2 = √2
284        // z_2 = z_0 = -1/√2
285        // m3 = ((-1/√2)³ + (√2)³ + (-1/√2)³) / 3
286        //    = (-1/(2√2) + 2√2 - 1/(2√2)) / 3
287        //    = (-1/√2 + 2√2) / 3
288        //    = (-√2/2 + 2√2) / 3
289        //    = (3√2/2) / 3
290        //    = √2/2
291        let expected = std::f64::consts::SQRT_2 / 2.0;
292        assert!((degree_skewness(&path3()).unwrap() - expected).abs() < 1e-10);
293    }
294
295    #[test]
296    fn skew_paw() {
297        // degrees: [2,2,3,1], mean=2, σ²=(0+0+1+1)/4=0.5, σ=1/√2
298        // z: [0,0,√2,-√2]
299        // m3 = (0+0+2√2-2√2)/4 = 0
300        assert!(degree_skewness(&paw()).unwrap().abs() < 1e-10);
301    }
302
303    // --- degree_kurtosis ---
304
305    #[test]
306    fn kurt_empty() {
307        let g = Graph::with_vertices(0);
308        assert!(degree_kurtosis(&g).unwrap().abs() < 1e-10);
309    }
310
311    #[test]
312    fn kurt_isolated() {
313        let g = Graph::with_vertices(5);
314        assert!(degree_kurtosis(&g).unwrap().abs() < 1e-10);
315    }
316
317    #[test]
318    fn kurt_regular_zero() {
319        assert!(degree_kurtosis(&k3()).unwrap().abs() < 1e-10);
320        assert!(degree_kurtosis(&k4()).unwrap().abs() < 1e-10);
321        assert!(degree_kurtosis(&cycle4()).unwrap().abs() < 1e-10);
322    }
323
324    #[test]
325    fn kurt_single_edge() {
326        // degrees: [1,1], mean=1, σ=0 → 0
327        assert!(degree_kurtosis(&single_edge()).unwrap().abs() < 1e-10);
328    }
329
330    #[test]
331    fn kurt_path3() {
332        // z: [-1/√2, √2, -1/√2]
333        // z⁴: [1/4, 4, 1/4]
334        // m4 = (1/4+4+1/4)/3 = 4.5/3 = 1.5
335        // excess kurtosis = 1.5 - 3 = -1.5
336        assert!((degree_kurtosis(&path3()).unwrap() - (-1.5)).abs() < 1e-10);
337    }
338
339    #[test]
340    fn kurt_paw() {
341        // degrees: [2,2,3,1], mean=2, σ²=0.5
342        // z: [0,0,√2,-√2]
343        // z⁴: [0,0,4,4]
344        // m4 = 8/4 = 2
345        // excess = 2 - 3 = -1
346        assert!((degree_kurtosis(&paw()).unwrap() - (-1.0)).abs() < 1e-10);
347    }
348
349    #[test]
350    fn kurt_star5() {
351        // degrees: [4,1,1,1,1], mean=8/5=1.6
352        // d-mean: [2.4,-0.6,-0.6,-0.6,-0.6]
353        // σ² = (5.76+0.36+0.36+0.36+0.36)/5 = 7.2/5 = 1.44
354        // σ = 1.2
355        // z: [2,-.5,-.5,-.5,-.5]
356        // z⁴: [16,.0625,.0625,.0625,.0625]
357        // m4 = (16+4·0.0625)/5 = 16.25/5 = 3.25
358        // excess = 3.25 - 3 = 0.25
359        assert!((degree_kurtosis(&star5()).unwrap() - 0.25).abs() < 1e-10);
360    }
361
362    // --- degree_gini ---
363
364    #[test]
365    fn gini_empty() {
366        let g = Graph::with_vertices(0);
367        assert!(degree_gini(&g).unwrap().abs() < 1e-10);
368    }
369
370    #[test]
371    fn gini_isolated() {
372        let g = Graph::with_vertices(5);
373        assert!(degree_gini(&g).unwrap().abs() < 1e-10);
374    }
375
376    #[test]
377    fn gini_regular_zero() {
378        assert!(degree_gini(&k3()).unwrap().abs() < 1e-10);
379        assert!(degree_gini(&k4()).unwrap().abs() < 1e-10);
380        assert!(degree_gini(&cycle4()).unwrap().abs() < 1e-10);
381    }
382
383    #[test]
384    fn gini_single_edge() {
385        assert!(degree_gini(&single_edge()).unwrap().abs() < 1e-10);
386    }
387
388    #[test]
389    fn gini_star5() {
390        // degrees: [4,1,1,1,1], mean=1.6
391        // Σ|d_i-d_j|: center vs each leaf: 4·3=12 (×2 for symmetry=24)
392        // leaf vs leaf: all 0
393        // total = 24
394        // Gini = 24 / (2·25·1.6) = 24/80 = 0.3
395        assert!((degree_gini(&star5()).unwrap() - 0.3).abs() < 1e-10);
396    }
397
398    #[test]
399    fn gini_path3() {
400        // degrees: [1,2,1], mean=4/3
401        // |d_i-d_j|:
402        //   (0,0)=0 (0,1)=1 (0,2)=0
403        //   (1,0)=1 (1,1)=0 (1,2)=1
404        //   (2,0)=0 (2,1)=1 (2,2)=0
405        // total = 4
406        // Gini = 4 / (2·9·4/3) = 4/24 = 1/6
407        let expected = 1.0 / 6.0;
408        assert!((degree_gini(&path3()).unwrap() - expected).abs() < 1e-10);
409    }
410
411    #[test]
412    fn gini_paw() {
413        // degrees: [2,2,3,1], mean=2
414        // |d_i-d_j|:
415        //   (0,1)=0 (0,2)=1 (0,3)=1 → row sum=2
416        //   (1,0)=0 (1,2)=1 (1,3)=1 → 2
417        //   (2,0)=1 (2,1)=1 (2,3)=2 → 4
418        //   (3,0)=1 (3,1)=1 (3,2)=2 → 4
419        // total = 12
420        // Gini = 12 / (2·16·2) = 12/64 = 3/16
421        let expected = 3.0 / 16.0;
422        assert!((degree_gini(&paw()).unwrap() - expected).abs() < 1e-10);
423    }
424
425    #[test]
426    fn gini_in_01() {
427        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
428            let val = degree_gini(g).unwrap();
429            assert!(val >= -1e-10);
430            assert!(val <= 1.0 + 1e-10);
431        }
432    }
433
434    // --- degree_max_deviation ---
435
436    #[test]
437    fn maxdev_empty() {
438        let g = Graph::with_vertices(0);
439        assert!(degree_max_deviation(&g).unwrap().abs() < 1e-10);
440    }
441
442    #[test]
443    fn maxdev_isolated() {
444        let g = Graph::with_vertices(5);
445        assert!(degree_max_deviation(&g).unwrap().abs() < 1e-10);
446    }
447
448    #[test]
449    fn maxdev_regular_zero() {
450        assert!(degree_max_deviation(&k3()).unwrap().abs() < 1e-10);
451        assert!(degree_max_deviation(&k4()).unwrap().abs() < 1e-10);
452        assert!(degree_max_deviation(&cycle4()).unwrap().abs() < 1e-10);
453    }
454
455    #[test]
456    fn maxdev_single_edge() {
457        assert!(degree_max_deviation(&single_edge()).unwrap().abs() < 1e-10);
458    }
459
460    #[test]
461    fn maxdev_star5() {
462        // mean=1.6, max|4-1.6|=2.4
463        assert!((degree_max_deviation(&star5()).unwrap() - 2.4).abs() < 1e-10);
464    }
465
466    #[test]
467    fn maxdev_path3() {
468        // mean=4/3, max|2-4/3|=2/3
469        let expected = 2.0 / 3.0;
470        assert!((degree_max_deviation(&path3()).unwrap() - expected).abs() < 1e-10);
471    }
472
473    #[test]
474    fn maxdev_paw() {
475        // mean=2, max|3-2|=1
476        assert!((degree_max_deviation(&paw()).unwrap() - 1.0).abs() < 1e-10);
477    }
478
479    #[test]
480    fn maxdev_nonnegative() {
481        for g in &[single_edge(), path3(), k3(), k4(), star5(), paw()] {
482            assert!(degree_max_deviation(g).unwrap() >= -1e-10);
483        }
484    }
485
486    // --- cross-consistency ---
487
488    #[test]
489    fn skew_kurt_consistent() {
490        // For symmetric distributions, skewness = 0
491        // Paw has symmetric degree dist around mean
492        assert!(degree_skewness(&paw()).unwrap().abs() < 1e-10);
493    }
494
495    #[test]
496    fn gini_zero_for_regular() {
497        // All regular graphs: Gini = 0
498        for g in &[k3(), k4(), cycle4()] {
499            assert!(degree_gini(g).unwrap().abs() < 1e-10);
500        }
501    }
502}