Skip to main content

rust_igraph/algorithms/properties/
assortativity_values.rs

1//! Value-based assortativity coefficient (ALGO-PR-067).
2//!
3//! Counterpart of the generic `igraph_assortativity()` from
4//! `references/igraph/src/misc/mixing.c`. Where [`super::assortativity`]
5//! (`assortativity_degree`) hard-wires each vertex's "value" to its
6//! degree, this entry takes an arbitrary numeric value per vertex and
7//! Pearson-correlates the endpoint values over the edge list.
8//!
9//! It backs python-igraph's `Graph.assortativity(types1, types2, ...)`
10//! and rigraph's `assortativity(graph, values, values.in, ...)`.
11//!
12//! Formula (matches upstream's float ordering at `mixing.c`):
13//!
14//! Undirected (each edge `(u, v)` with optional weight `w`, default 1):
15//! ```text
16//! W    = Σ w                     (= m when unweighted)
17//! num1 = Σ w · value[u] · value[v]
18//! num2 = Σ w · (value[u] + value[v])
19//! den1 = Σ w · (value[u]² + value[v]²)      (only when normalized)
20//! num1 /= W;  den1 /= 2W;  num2 /= 2W;  num2 = num2²
21//! r = normalized ? (num1 − num2) / (den1 − num2) : (num1 − num2)
22//! ```
23//!
24//! Directed (source value from `values`, target value from `values_in`,
25//! which defaults to `values`):
26//! ```text
27//! num1 = Σ w · value[u] · value_in[v]
28//! num2 = Σ w · value[u]
29//! num3 = Σ w · value_in[v]
30//! den1 = Σ w · value[u]²            (only when normalized)
31//! den2 = Σ w · value_in[v]²         (only when normalized)
32//! num = num1 − num2 · num3 / W
33//! den = sqrt(den1 − num2²/W) · sqrt(den2 − num3²/W)
34//! r = normalized ? num / den : num / W
35//! ```
36
37use crate::core::graph::EdgeId;
38use crate::core::{Graph, IgraphError, IgraphResult};
39
40/// Value-based assortativity coefficient of `graph`.
41///
42/// - `values`: numeric value for each vertex (length must equal the
43///   vertex count). For directed graphs these are the **source** values.
44/// - `values_in`: optional target values for directed graphs (length must
45///   equal the vertex count). When `None`, `values` is reused for both
46///   endpoints. Ignored for undirected graphs.
47/// - `weights`: optional per-edge weights (length must equal the edge
48///   count). When `None`, every edge contributes weight 1.
49/// - `directed`: respect edge orientation; only takes effect when the
50///   graph itself is directed.
51/// - `normalized`: when `true`, return the standard normalized
52///   (Pearson-style) coefficient in `[-1, 1]`; when `false`, return the
53///   unnormalized covariance-style value.
54///
55/// Returns `None` when the coefficient is undefined (no edges, zero total
56/// weight, or a vanishing variance denominator) — mirroring upstream's
57/// `IGRAPH_NAN`.
58///
59/// # Errors
60///
61/// Returns [`IgraphError::InvalidArgument`] if any input vector length
62/// does not match the corresponding count.
63///
64/// # References
65///
66/// M. E. J. Newman: Mixing patterns in networks,
67/// Phys. Rev. E 67, 026126 (2003).
68///
69/// # Examples
70///
71/// ```
72/// use rust_igraph::{Graph, assortativity};
73///
74/// // Path 0-1-2 with values mirroring the degrees [1, 2, 1] reproduces
75/// // the degree-assortativity result of -1.0 (perfectly disassortative).
76/// let mut g = Graph::with_vertices(3);
77/// g.add_edge(0, 1).unwrap();
78/// g.add_edge(1, 2).unwrap();
79/// let values = [1.0, 2.0, 1.0];
80/// let r = assortativity(&g, &values, None, None, false, true).unwrap();
81/// assert!((r.unwrap() - (-1.0)).abs() < 1e-12);
82/// ```
83pub fn assortativity(
84    graph: &Graph,
85    values: &[f64],
86    values_in: Option<&[f64]>,
87    weights: Option<&[f64]>,
88    directed: bool,
89    normalized: bool,
90) -> IgraphResult<Option<f64>> {
91    let n = graph.vcount() as usize;
92    let m = graph.ecount();
93
94    if values.len() != n {
95        return Err(IgraphError::InvalidArgument(format!(
96            "assortativity: values length ({}) does not match vertex count ({n})",
97            values.len()
98        )));
99    }
100    if let Some(vin) = values_in {
101        if vin.len() != n {
102            return Err(IgraphError::InvalidArgument(format!(
103                "assortativity: values_in length ({}) does not match vertex count ({n})",
104                vin.len()
105            )));
106        }
107    }
108    if let Some(w) = weights {
109        if w.len() != m {
110            return Err(IgraphError::InvalidArgument(format!(
111                "assortativity: weights length ({}) does not match edge count ({m})",
112                w.len()
113            )));
114        }
115    }
116
117    let directed = directed && graph.is_directed();
118
119    let m_u32 = u32::try_from(m)
120        .map_err(|_| IgraphError::Internal("ecount overflows u32 for assortativity"))?;
121
122    #[allow(clippy::cast_precision_loss)]
123    let total_weight = match weights {
124        Some(w) => w.iter().sum::<f64>(),
125        None => m as f64,
126    };
127
128    let result = if directed {
129        assortativity_directed(
130            graph,
131            values,
132            values_in.unwrap_or(values),
133            weights,
134            normalized,
135            total_weight,
136            m_u32,
137        )?
138    } else {
139        assortativity_undirected(graph, values, weights, normalized, total_weight, m_u32)?
140    };
141
142    Ok(if result.is_finite() {
143        Some(result)
144    } else {
145        None
146    })
147}
148
149#[allow(clippy::too_many_arguments)]
150fn assortativity_undirected(
151    graph: &Graph,
152    values: &[f64],
153    weights: Option<&[f64]>,
154    normalized: bool,
155    total_weight: f64,
156    m_u32: u32,
157) -> IgraphResult<f64> {
158    let mut num1 = 0.0_f64;
159    let mut num2 = 0.0_f64;
160    let mut den1 = 0.0_f64;
161
162    for e in 0..m_u32 {
163        let (from, to) = graph.edge(e as EdgeId)?;
164        let from_value = values[from as usize];
165        let to_value = values[to as usize];
166        let w = weights.map_or(1.0, |ws| ws[e as usize]);
167
168        num1 += w * from_value * to_value;
169        num2 += w * (from_value + to_value);
170        if normalized {
171            den1 += w * (from_value * from_value + to_value * to_value);
172        }
173    }
174
175    num1 /= total_weight;
176    if normalized {
177        den1 /= total_weight * 2.0;
178    }
179    num2 /= total_weight * 2.0;
180    num2 *= num2;
181
182    Ok(if normalized {
183        (num1 - num2) / (den1 - num2)
184    } else {
185        num1 - num2
186    })
187}
188
189#[allow(clippy::too_many_arguments)]
190fn assortativity_directed(
191    graph: &Graph,
192    values: &[f64],
193    values_in: &[f64],
194    weights: Option<&[f64]>,
195    normalized: bool,
196    total_weight: f64,
197    m_u32: u32,
198) -> IgraphResult<f64> {
199    let mut num1 = 0.0_f64;
200    let mut num2 = 0.0_f64;
201    let mut num3 = 0.0_f64;
202    let mut den1 = 0.0_f64;
203    let mut den2 = 0.0_f64;
204
205    for e in 0..m_u32 {
206        let (from, to) = graph.edge(e as EdgeId)?;
207        let from_value = values[from as usize];
208        let to_value = values_in[to as usize];
209        let w = weights.map_or(1.0, |ws| ws[e as usize]);
210
211        num1 += w * from_value * to_value;
212        num2 += w * from_value;
213        num3 += w * to_value;
214        if normalized {
215            den1 += w * (from_value * from_value);
216            den2 += w * (to_value * to_value);
217        }
218    }
219
220    let num = num1 - num2 * num3 / total_weight;
221    Ok(if normalized {
222        let den =
223            (den1 - num2 * num2 / total_weight).sqrt() * (den2 - num3 * num3 / total_weight).sqrt();
224        num / den
225    } else {
226        num / total_weight
227    })
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn assert_close(a: f64, b: f64, tol: f64) {
235        assert!(
236            (a - b).abs() < tol,
237            "expected {b} ± {tol}, got {a} (diff {})",
238            (a - b).abs()
239        );
240    }
241
242    #[test]
243    fn no_edges_is_none() {
244        let g = Graph::with_vertices(3);
245        let values = [1.0, 2.0, 3.0];
246        assert_eq!(
247            assortativity(&g, &values, None, None, false, true).unwrap(),
248            None
249        );
250    }
251
252    #[test]
253    fn wrong_values_length_errors() {
254        let mut g = Graph::with_vertices(3);
255        g.add_edge(0, 1).unwrap();
256        let values = [1.0, 2.0];
257        assert!(assortativity(&g, &values, None, None, false, true).is_err());
258    }
259
260    #[test]
261    fn wrong_weights_length_errors() {
262        let mut g = Graph::with_vertices(3);
263        g.add_edge(0, 1).unwrap();
264        g.add_edge(1, 2).unwrap();
265        let values = [1.0, 2.0, 1.0];
266        let weights = [1.0];
267        assert!(assortativity(&g, &values, None, Some(&weights), false, true).is_err());
268    }
269
270    #[test]
271    fn path_with_degree_values_matches_degree_assortativity() {
272        // 0-1-2 with values = degrees [1,2,1] → r = -1.0 (matches
273        // assortativity_degree on the same path).
274        let mut g = Graph::with_vertices(3);
275        g.add_edge(0, 1).unwrap();
276        g.add_edge(1, 2).unwrap();
277        let values = [1.0, 2.0, 1.0];
278        let r = assortativity(&g, &values, None, None, false, true)
279            .unwrap()
280            .unwrap();
281        assert_close(r, -1.0, 1e-12);
282    }
283
284    #[test]
285    fn constant_values_zero_variance_is_none() {
286        // All vertices share the same value → variance denominator
287        // vanishes → undefined → None (normalized).
288        let mut g = Graph::with_vertices(4);
289        for i in 0..4u32 {
290            g.add_edge(i, (i + 1) % 4).unwrap();
291        }
292        let values = [5.0, 5.0, 5.0, 5.0];
293        assert_eq!(
294            assortativity(&g, &values, None, None, false, true).unwrap(),
295            None
296        );
297    }
298
299    #[test]
300    fn perfectly_assortative_two_components() {
301        // Two edges 0-1 (values 1,1) and 2-3 (values 9,9): like values
302        // always paired → r = +1.0.
303        let mut g = Graph::with_vertices(4);
304        g.add_edge(0, 1).unwrap();
305        g.add_edge(2, 3).unwrap();
306        let values = [1.0, 1.0, 9.0, 9.0];
307        let r = assortativity(&g, &values, None, None, false, true)
308            .unwrap()
309            .unwrap();
310        assert_close(r, 1.0, 1e-12);
311    }
312
313    #[test]
314    fn unnormalized_is_covariance_like() {
315        // Path 0-1-2 with values [1,2,1], unnormalized.
316        // num1 = 1*2 + 2*1 = 4; /m=4/2 = 2
317        // num2 = (1+2)+(2+1) = 6; /(2*2) = 1.5; ^2 = 2.25
318        // r = 2 - 2.25 = -0.25
319        let mut g = Graph::with_vertices(3);
320        g.add_edge(0, 1).unwrap();
321        g.add_edge(1, 2).unwrap();
322        let values = [1.0, 2.0, 1.0];
323        let r = assortativity(&g, &values, None, None, false, false)
324            .unwrap()
325            .unwrap();
326        assert_close(r, -0.25, 1e-12);
327    }
328
329    #[test]
330    fn weights_reweight_edges() {
331        // Path 0-1-2 with values [1,2,3]. Unweighted normalized r:
332        //   num1 = 1*2 + 2*3 = 8; /2 = 4
333        //   num2 = (1+2)+(2+3)=8; /(4) = 2; ^2 = 4
334        //   den1 = (1+4)+(4+9)=18; /(4) = 4.5
335        //   r = (4-4)/(4.5-4) = 0
336        // With weights [3,1] the first edge dominates → different value.
337        let mut g = Graph::with_vertices(3);
338        g.add_edge(0, 1).unwrap();
339        g.add_edge(1, 2).unwrap();
340        let values = [1.0, 2.0, 3.0];
341        let r_unw = assortativity(&g, &values, None, None, false, true)
342            .unwrap()
343            .unwrap();
344        assert_close(r_unw, 0.0, 1e-12);
345        let weights = [3.0, 1.0];
346        let r_w = assortativity(&g, &values, None, Some(&weights), false, true)
347            .unwrap()
348            .unwrap();
349        // With heavier weight on the (1,2) — wait edges are (0,1),(1,2);
350        // weight 3 on (0,1). Just assert it differs and is finite.
351        assert!(r_w.is_finite());
352        assert!((r_w - r_unw).abs() > 1e-9);
353    }
354
355    #[test]
356    fn directed_uses_source_and_target_values() {
357        // 0→1, 0→2, 1→2 with source values = out-degree [2,1,0] and
358        // target values_in = in-degree [0,1,2] reproduces the directed
359        // degree assortativity result of -0.5.
360        let mut g = Graph::new(3, true).unwrap();
361        g.add_edge(0, 1).unwrap();
362        g.add_edge(1, 2).unwrap();
363        g.add_edge(0, 2).unwrap();
364        let out_values = [2.0, 1.0, 0.0];
365        let in_values = [0.0, 1.0, 2.0];
366        let r = assortativity(&g, &out_values, Some(&in_values), None, true, true)
367            .unwrap()
368            .unwrap();
369        assert_close(r, -0.5, 1e-12);
370    }
371
372    #[test]
373    fn directed_flag_ignored_on_undirected_graph() {
374        // directed=true on an undirected graph should behave like the
375        // undirected formula (values_in ignored).
376        let mut g = Graph::with_vertices(3);
377        g.add_edge(0, 1).unwrap();
378        g.add_edge(1, 2).unwrap();
379        let values = [1.0, 2.0, 1.0];
380        let a = assortativity(&g, &values, None, None, false, true).unwrap();
381        let b = assortativity(&g, &values, None, None, true, true).unwrap();
382        assert_eq!(a, b);
383    }
384
385    #[test]
386    fn directed_without_values_in_reuses_values() {
387        // Passing values_in=None for a directed graph reuses `values`
388        // for both endpoints.
389        let mut g = Graph::new(4, true).unwrap();
390        g.add_edge(0, 1).unwrap();
391        g.add_edge(1, 2).unwrap();
392        g.add_edge(2, 3).unwrap();
393        let values = [1.0, 2.0, 3.0, 4.0];
394        let r = assortativity(&g, &values, None, None, true, true).unwrap();
395        assert!(r.is_some());
396    }
397}