Skip to main content

rust_igraph/algorithms/properties/
structural_features.rs

1//! Per-vertex structural feature vectors (ALGO-TR-017).
2//!
3//! Assembles multi-dimensional structural descriptors for each vertex,
4//! combining degree statistics, local clustering, and neighborhood
5//! topology into feature matrices suitable for GNN input or classical
6//! ML on graphs.
7
8#![allow(
9    clippy::cast_possible_truncation,
10    clippy::cast_precision_loss,
11    clippy::needless_range_loop
12)]
13
14use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
15
16/// Per-vertex structural feature vector.
17#[derive(Debug, Clone, PartialEq)]
18pub struct StructuralFeatures {
19    /// Number of vertices.
20    pub num_vertices: usize,
21    /// Number of features per vertex.
22    pub num_features: usize,
23    /// Feature matrix in row-major order: `features[v * num_features + f]`.
24    pub features: Vec<f64>,
25    /// Feature names in column order.
26    pub feature_names: Vec<&'static str>,
27}
28
29impl StructuralFeatures {
30    /// Get feature vector for vertex `v`.
31    pub fn vertex_features(&self, v: usize) -> &[f64] {
32        let start = v * self.num_features;
33        &self.features[start..start + self.num_features]
34    }
35}
36
37/// Compute structural feature vectors for all vertices.
38///
39/// Each vertex gets a feature vector containing:
40/// 0. `degree` — vertex degree
41/// 1. `log_degree` — log2(1 + degree)
42/// 2. `clustering` — local clustering coefficient
43/// 3. `avg_neighbor_degree` — mean degree of neighbors
44/// 4. `min_neighbor_degree` — minimum degree among neighbors
45/// 5. `max_neighbor_degree` — maximum degree among neighbors
46/// 6. `triangles` — number of triangles containing this vertex
47/// 7. `square_clustering` — fraction of possible squares closed
48///
49/// # Examples
50///
51/// ```
52/// use rust_igraph::{Graph, structural_feature_vectors};
53///
54/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2),(2,3)], false, Some(4)).unwrap();
55/// let sf = structural_feature_vectors(&g).unwrap();
56/// assert_eq!(sf.num_vertices, 4);
57/// assert_eq!(sf.num_features, 8);
58/// // Vertex 0 has degree 2
59/// assert!((sf.vertex_features(0)[0] - 2.0).abs() < 1e-10);
60/// ```
61pub fn structural_feature_vectors(graph: &Graph) -> IgraphResult<StructuralFeatures> {
62    if graph.is_directed() {
63        return Err(IgraphError::InvalidArgument(
64            "structural_feature_vectors is defined for undirected graphs only".to_string(),
65        ));
66    }
67
68    let nv = graph.vcount() as usize;
69    let num_features = 8;
70
71    // Pre-compute degrees and neighbor lists
72    let mut degrees = Vec::with_capacity(nv);
73    let mut neighbors_cache: Vec<Vec<VertexId>> = Vec::with_capacity(nv);
74    for v in 0..nv {
75        let vid = v as VertexId;
76        degrees.push(graph.degree(vid)?);
77        neighbors_cache.push(graph.neighbors(vid)?);
78    }
79
80    let mut features = vec![0.0_f64; nv * num_features];
81
82    for v in 0..nv {
83        let offset = v * num_features;
84        let deg = degrees[v];
85        let neighbors = &neighbors_cache[v];
86
87        // Feature 0: degree
88        features[offset] = deg as f64;
89
90        // Feature 1: log_degree
91        features[offset + 1] = (1.0 + deg as f64).log2();
92
93        // Feature 2: local clustering coefficient
94        features[offset + 2] = local_clustering(&neighbors_cache, v);
95
96        // Feature 3-5: neighbor degree statistics
97        if deg > 0 {
98            let mut sum_nd = 0usize;
99            let mut min_nd = usize::MAX;
100            let mut max_nd = 0usize;
101            for &u in neighbors {
102                let nd = degrees[u as usize];
103                sum_nd += nd;
104                if nd < min_nd {
105                    min_nd = nd;
106                }
107                if nd > max_nd {
108                    max_nd = nd;
109                }
110            }
111            features[offset + 3] = sum_nd as f64 / deg as f64;
112            features[offset + 4] = min_nd as f64;
113            features[offset + 5] = max_nd as f64;
114        }
115
116        // Feature 6: triangles
117        let tri = count_vertex_triangles(&neighbors_cache, v);
118        features[offset + 6] = tri as f64;
119
120        // Feature 7: square clustering (fraction of possible squares closed)
121        features[offset + 7] = square_clustering_coeff(&neighbors_cache, &degrees, v);
122    }
123
124    Ok(StructuralFeatures {
125        num_vertices: nv,
126        num_features,
127        features,
128        feature_names: vec![
129            "degree",
130            "log_degree",
131            "clustering",
132            "avg_neighbor_degree",
133            "min_neighbor_degree",
134            "max_neighbor_degree",
135            "triangles",
136            "square_clustering",
137        ],
138    })
139}
140
141/// Compute degree profile for each vertex: [deg, deg², log(deg+1)].
142///
143/// A lightweight alternative to the full feature vector when only
144/// degree-based features are needed.
145///
146/// Returns a flat `Vec<f64>` of length `3 * vcount`, row-major.
147///
148/// # Examples
149///
150/// ```
151/// use rust_igraph::{Graph, degree_profile};
152///
153/// let g = Graph::from_edges(&[(0,1),(1,2),(0,2)], false, Some(3)).unwrap();
154/// let prof = degree_profile(&g).unwrap();
155/// // Vertex 0: deg=2, deg²=4, log(3)
156/// assert!((prof[0] - 2.0).abs() < 1e-10);
157/// assert!((prof[1] - 4.0).abs() < 1e-10);
158/// ```
159pub fn degree_profile(graph: &Graph) -> IgraphResult<Vec<f64>> {
160    let nv = graph.vcount() as usize;
161    let mut result = Vec::with_capacity(nv * 3);
162
163    for v in 0..nv {
164        let d = graph.degree(v as VertexId)? as f64;
165        result.push(d);
166        result.push(d * d);
167        result.push((d + 1.0).log2());
168    }
169
170    Ok(result)
171}
172
173// --- Internal helpers ---
174
175fn local_clustering(neighbors_cache: &[Vec<VertexId>], v: usize) -> f64 {
176    let neighbors = &neighbors_cache[v];
177    let deg = neighbors.len();
178    if deg < 2 {
179        return 0.0;
180    }
181
182    let mut triangles = 0usize;
183    for i in 0..deg {
184        let ni = neighbors[i] as usize;
185        for j in (i + 1)..deg {
186            let nj = neighbors[j];
187            if neighbors_cache[ni].contains(&nj) {
188                triangles += 1;
189            }
190        }
191    }
192
193    let possible = deg * (deg - 1) / 2;
194    triangles as f64 / possible as f64
195}
196
197fn count_vertex_triangles(neighbors_cache: &[Vec<VertexId>], v: usize) -> usize {
198    let neighbors = &neighbors_cache[v];
199    let deg = neighbors.len();
200    let mut count = 0;
201
202    for i in 0..deg {
203        let ni = neighbors[i] as usize;
204        for j in (i + 1)..deg {
205            let nj = neighbors[j];
206            if neighbors_cache[ni].contains(&nj) {
207                count += 1;
208            }
209        }
210    }
211
212    count
213}
214
215fn square_clustering_coeff(neighbors_cache: &[Vec<VertexId>], degrees: &[usize], v: usize) -> f64 {
216    // Square clustering: for vertex v, count pairs of neighbors (u, w)
217    // that have a common neighbor other than v (forming a 4-cycle through v).
218    let neighbors = &neighbors_cache[v];
219    let deg = neighbors.len();
220    if deg < 2 {
221        return 0.0;
222    }
223
224    let mut squares = 0usize;
225    for i in 0..deg {
226        let u = neighbors[i] as usize;
227        for j in (i + 1)..deg {
228            let w = neighbors[j];
229            // Count common neighbors of u and w, excluding v
230            for &x in &neighbors_cache[u] {
231                if x != v as u32 && x != w && neighbors_cache[w as usize].contains(&x) {
232                    squares += 1;
233                    break; // Only count once per pair
234                }
235            }
236        }
237    }
238
239    // Maximum possible squares: C(deg, 2) pairs, each could form a square
240    let possible = deg * (deg - 1) / 2;
241    if possible == 0 {
242        return 0.0;
243    }
244
245    let _ = degrees;
246    squares as f64 / possible as f64
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn triangle() -> Graph {
254        Graph::from_edges(&[(0, 1), (1, 2), (0, 2)], false, Some(3)).unwrap()
255    }
256
257    fn path4() -> Graph {
258        Graph::from_edges(&[(0, 1), (1, 2), (2, 3)], false, Some(4)).unwrap()
259    }
260
261    fn square_graph() -> Graph {
262        Graph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)], false, Some(4)).unwrap()
263    }
264
265    // --- structural_feature_vectors tests ---
266
267    #[test]
268    fn sf_triangle_features() {
269        let g = triangle();
270        let sf = structural_feature_vectors(&g).unwrap();
271        assert_eq!(sf.num_vertices, 3);
272        assert_eq!(sf.num_features, 8);
273        // All vertices in K3 have degree 2
274        for v in 0..3 {
275            let f = sf.vertex_features(v);
276            assert!((f[0] - 2.0).abs() < 1e-10); // degree
277            assert!((f[2] - 1.0).abs() < 1e-10); // clustering = 1 in K3
278            assert!((f[3] - 2.0).abs() < 1e-10); // avg_neighbor_deg = 2
279            assert!((f[6] - 1.0).abs() < 1e-10); // 1 triangle per vertex
280        }
281    }
282
283    #[test]
284    fn sf_path_features() {
285        let g = path4();
286        let sf = structural_feature_vectors(&g).unwrap();
287        // Vertex 0: deg=1, clustering=0, triangles=0
288        let f0 = sf.vertex_features(0);
289        assert!((f0[0] - 1.0).abs() < 1e-10);
290        assert!(f0[2].abs() < 1e-10);
291        assert!(f0[6].abs() < 1e-10);
292
293        // Vertex 1: deg=2, clustering=0
294        let f1 = sf.vertex_features(1);
295        assert!((f1[0] - 2.0).abs() < 1e-10);
296        assert!(f1[2].abs() < 1e-10);
297    }
298
299    #[test]
300    fn sf_empty_graph() {
301        let g = Graph::with_vertices(3);
302        let sf = structural_feature_vectors(&g).unwrap();
303        for v in 0..3 {
304            let f = sf.vertex_features(v);
305            assert!(f[0].abs() < 1e-10); // degree 0
306        }
307    }
308
309    #[test]
310    fn sf_directed_error() {
311        let g = Graph::from_edges(&[(0, 1)], true, Some(2)).unwrap();
312        assert!(structural_feature_vectors(&g).is_err());
313    }
314
315    #[test]
316    fn sf_feature_names() {
317        let g = triangle();
318        let sf = structural_feature_vectors(&g).unwrap();
319        assert_eq!(sf.feature_names.len(), 8);
320        assert_eq!(sf.feature_names[0], "degree");
321        assert_eq!(sf.feature_names[7], "square_clustering");
322    }
323
324    #[test]
325    fn sf_square_clustering() {
326        let g = square_graph();
327        let sf = structural_feature_vectors(&g).unwrap();
328        // In C4, each vertex has 2 neighbors forming 1 square
329        // square_clustering for each vertex: pair (u,w) has common neighbor → 1.0
330        for v in 0..4 {
331            let f = sf.vertex_features(v);
332            assert!((f[7] - 1.0).abs() < 1e-10);
333        }
334    }
335
336    // --- degree_profile tests ---
337
338    #[test]
339    fn dp_triangle() {
340        let g = triangle();
341        let prof = degree_profile(&g).unwrap();
342        assert_eq!(prof.len(), 9); // 3 vertices × 3 features
343        // Vertex 0: deg=2, deg²=4, log2(3)
344        assert!((prof[0] - 2.0).abs() < 1e-10);
345        assert!((prof[1] - 4.0).abs() < 1e-10);
346        assert!((prof[2] - 3.0_f64.log2()).abs() < 1e-10);
347    }
348
349    #[test]
350    fn dp_empty() {
351        let g = Graph::with_vertices(2);
352        let prof = degree_profile(&g).unwrap();
353        assert_eq!(prof.len(), 6);
354        // All zeros for degree, degree²
355        assert!(prof[0].abs() < 1e-10);
356        assert!(prof[1].abs() < 1e-10);
357        // log2(0+1) = 0
358        assert!(prof[2].abs() < 1e-10);
359    }
360
361    #[test]
362    fn dp_star() {
363        let g = Graph::from_edges(&[(0, 1), (0, 2), (0, 3)], false, Some(4)).unwrap();
364        let prof = degree_profile(&g).unwrap();
365        // Vertex 0: deg=3, deg²=9
366        assert!((prof[0] - 3.0).abs() < 1e-10);
367        assert!((prof[1] - 9.0).abs() < 1e-10);
368    }
369}