Skip to main content

rust_igraph/algorithms/properties/
assortativity_nominal.rs

1//! Categorical (nominal) assortativity coefficient (ALGO-PR-046).
2//!
3//! Computes the assortativity coefficient of a graph based on discrete
4//! vertex categories. Measures the tendency of vertices to connect to
5//! others in the same category.
6//!
7//! Counterpart of `igraph_assortativity_nominal` from
8//! `references/igraph/src/misc/mixing.c`.
9
10use crate::core::{Graph, IgraphError, IgraphResult};
11
12/// Compute categorical (nominal) assortativity coefficient.
13///
14/// Given integer category labels for each vertex, measures whether edges
15/// tend to connect vertices of the same type (positive) or different
16/// types (negative). The coefficient ranges from -1 to 1: +1 means all
17/// edges stay within categories, 0 means random mixing, -1 means
18/// perfectly disassortative.
19///
20/// The unnormalized version equals the modularity.
21///
22/// - `types`: integer category label for each vertex (0-based, length must
23///   equal vertex count).
24/// - `directed`: if true and the graph is directed, respect edge direction;
25///   otherwise treat as undirected (each edge counted in both directions).
26/// - `normalized`: if true, return the standard normalized assortativity;
27///   if false, return the unnormalized version (= modularity).
28///
29/// Returns `f64::NAN` for graphs with no vertices or no edges.
30///
31/// # References
32///
33/// M. E. J. Newman: Mixing patterns in networks,
34/// Phys. Rev. E 67, 026126 (2003).
35///
36/// # Examples
37///
38/// ```
39/// use rust_igraph::{Graph, assortativity_nominal};
40///
41/// // Perfect assortative: all edges within same type
42/// let mut g = Graph::with_vertices(4);
43/// g.add_edge(0, 1).unwrap(); // type 0 - type 0
44/// g.add_edge(2, 3).unwrap(); // type 1 - type 1
45/// let types = vec![0u32, 0, 1, 1];
46/// let r = assortativity_nominal(&g, &types, false, true).unwrap();
47/// assert!(r > 0.99); // close to 1.0
48/// ```
49pub fn assortativity_nominal(
50    graph: &Graph,
51    types: &[u32],
52    directed: bool,
53    normalized: bool,
54) -> IgraphResult<f64> {
55    let n = graph.vcount();
56    let ecount = graph.ecount();
57
58    #[allow(clippy::cast_possible_truncation)]
59    if types.len() != n as usize {
60        return Err(IgraphError::InvalidArgument(format!(
61            "assortativity_nominal: types length ({}) does not match vertex count ({n})",
62            types.len()
63        )));
64    }
65
66    if n == 0 || ecount == 0 {
67        return Ok(f64::NAN);
68    }
69
70    let directed = directed && graph.is_directed();
71
72    let no_of_types = types
73        .iter()
74        .copied()
75        .max()
76        .unwrap_or(0)
77        .checked_add(1)
78        .ok_or_else(|| {
79            IgraphError::InvalidArgument("assortativity_nominal: type value overflow".to_string())
80        })? as usize;
81
82    let mut ai: Vec<u64> = vec![0; no_of_types];
83    let mut bi: Vec<u64> = vec![0; no_of_types];
84    let mut eii: Vec<u64> = vec![0; no_of_types];
85
86    for eid in 0..ecount {
87        #[allow(clippy::cast_possible_truncation)]
88        let (from, to) = graph.edge(eid as u32)?;
89        let from_type = types[from as usize] as usize;
90        let to_type = types[to as usize] as usize;
91
92        ai[from_type] += 1;
93        bi[to_type] += 1;
94        if from_type == to_type {
95            eii[from_type] += 1;
96        }
97        if !directed {
98            if from_type == to_type {
99                eii[from_type] += 1;
100            }
101            ai[to_type] += 1;
102            bi[from_type] += 1;
103        }
104    }
105
106    #[allow(clippy::cast_precision_loss)]
107    let m = ecount as f64;
108
109    let mut sumaibi = 0.0_f64;
110    let mut sumeii = 0.0_f64;
111
112    for i in 0..no_of_types {
113        #[allow(clippy::cast_precision_loss)]
114        let ai_f = ai[i] as f64;
115        #[allow(clippy::cast_precision_loss)]
116        let bi_f = bi[i] as f64;
117        #[allow(clippy::cast_precision_loss)]
118        let eii_f = eii[i] as f64;
119
120        sumaibi += (ai_f / m) * (bi_f / m);
121        sumeii += eii_f / m;
122    }
123
124    if !directed {
125        sumaibi /= 4.0;
126        sumeii /= 2.0;
127    }
128
129    if normalized {
130        let denom = 1.0 - sumaibi;
131        if denom.abs() < f64::EPSILON {
132            return Ok(f64::NAN);
133        }
134        Ok((sumeii - sumaibi) / denom)
135    } else {
136        Ok(sumeii - sumaibi)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn approx_eq(a: f64, b: f64) -> bool {
145        (a - b).abs() < 1e-10
146    }
147
148    #[test]
149    fn empty_graph() {
150        let g = Graph::with_vertices(0);
151        let r = assortativity_nominal(&g, &[], false, true).unwrap();
152        assert!(r.is_nan());
153    }
154
155    #[test]
156    fn no_edges() {
157        let g = Graph::with_vertices(3);
158        let types = vec![0, 1, 2];
159        let r = assortativity_nominal(&g, &types, false, true).unwrap();
160        assert!(r.is_nan());
161    }
162
163    #[test]
164    fn perfect_assortative_undirected() {
165        // Two clusters, all internal edges
166        let mut g = Graph::with_vertices(4);
167        g.add_edge(0, 1).unwrap();
168        g.add_edge(2, 3).unwrap();
169        let types = vec![0, 0, 1, 1];
170        let r = assortativity_nominal(&g, &types, false, true).unwrap();
171        assert!(approx_eq(r, 1.0));
172    }
173
174    #[test]
175    fn perfect_disassortative_undirected() {
176        // Complete bipartite K2,2: all edges cross types
177        let mut g = Graph::with_vertices(4);
178        g.add_edge(0, 2).unwrap();
179        g.add_edge(0, 3).unwrap();
180        g.add_edge(1, 2).unwrap();
181        g.add_edge(1, 3).unwrap();
182        let types = vec![0, 0, 1, 1];
183        let r = assortativity_nominal(&g, &types, false, true).unwrap();
184        assert!(approx_eq(r, -1.0));
185    }
186
187    #[test]
188    fn mixed_undirected() {
189        let mut g = Graph::with_vertices(3);
190        g.add_edge(0, 1).unwrap(); // type 0 - type 0
191        g.add_edge(1, 2).unwrap(); // type 0 - type 1
192        g.add_edge(0, 2).unwrap(); // type 0 - type 1
193        let types = vec![0, 0, 1];
194        let r = assortativity_nominal(&g, &types, false, true).unwrap();
195        assert!(approx_eq(r, -0.5));
196    }
197
198    #[test]
199    fn directed_graph() {
200        let mut g = Graph::new(3, true).unwrap();
201        g.add_edge(0, 1).unwrap(); // type 0 → type 0
202        g.add_edge(1, 2).unwrap(); // type 0 → type 1
203        let types = vec![0, 0, 1];
204        let r = assortativity_nominal(&g, &types, true, true).unwrap();
205        // Verified against python-igraph: 0.0
206        assert!(approx_eq(r, 0.0));
207    }
208
209    #[test]
210    fn directed_as_undirected() {
211        let mut g = Graph::new(4, true).unwrap();
212        g.add_edge(0, 1).unwrap();
213        g.add_edge(2, 3).unwrap();
214        let types = vec![0, 0, 1, 1];
215        // directed=false → treat as undirected
216        let r = assortativity_nominal(&g, &types, false, true).unwrap();
217        assert!(approx_eq(r, 1.0));
218    }
219
220    #[test]
221    fn unnormalized_equals_modularity() {
222        let mut g = Graph::with_vertices(4);
223        g.add_edge(0, 1).unwrap();
224        g.add_edge(2, 3).unwrap();
225        g.add_edge(1, 2).unwrap();
226        let types = vec![0, 0, 1, 1];
227        let r = assortativity_nominal(&g, &types, false, false).unwrap();
228        // Verified against python-igraph: 1/6
229        assert!(approx_eq(r, 1.0 / 6.0));
230    }
231
232    #[test]
233    fn types_length_mismatch() {
234        let g = Graph::with_vertices(3);
235        let types = vec![0, 1]; // wrong length
236        assert!(assortativity_nominal(&g, &types, false, true).is_err());
237    }
238
239    #[test]
240    fn single_type_all_same() {
241        let mut g = Graph::with_vertices(3);
242        g.add_edge(0, 1).unwrap();
243        g.add_edge(1, 2).unwrap();
244        let types = vec![0, 0, 0];
245        let r = assortativity_nominal(&g, &types, false, true).unwrap();
246        // All same type → sumaibi=1.0, sumeii=1.0
247        // (1-1)/(1-1) → NaN (degenerate)
248        assert!(r.is_nan());
249    }
250
251    #[test]
252    fn self_loop_undirected() {
253        let mut g = Graph::with_vertices(2);
254        g.add_edge(0, 0).unwrap(); // self-loop
255        g.add_edge(0, 1).unwrap();
256        let types = vec![0, 1];
257        let r = assortativity_nominal(&g, &types, false, true).unwrap();
258        // Verified against python-igraph: -1/3
259        assert!(approx_eq(r, -1.0 / 3.0));
260    }
261
262    #[test]
263    fn many_types() {
264        let mut g = Graph::with_vertices(6);
265        g.add_edge(0, 1).unwrap(); // type 0 - type 1
266        g.add_edge(2, 3).unwrap(); // type 2 - type 0
267        g.add_edge(4, 5).unwrap(); // type 1 - type 2
268        let types = vec![0, 1, 2, 0, 1, 2];
269        let r = assortativity_nominal(&g, &types, false, true).unwrap();
270        assert!(r < 0.0);
271    }
272}