Skip to main content

scirs2_graph/alignment/
evaluation.rs

1//! Alignment quality evaluation metrics.
2//!
3//! Provides functions for measuring the quality of a network alignment,
4//! including edge conservation, symmetric substructure score, node correctness,
5//! and induced conserved structure.
6
7use scirs2_core::ndarray::Array2;
8
9/// Edge Conservation (EC): fraction of edges in G1 preserved in the mapping to G2.
10///
11/// For each edge `(u, v)` in G1, checks whether the mapped nodes `(f(u), f(v))`
12/// also form an edge in G2.
13///
14/// # Returns
15///
16/// A value in `[0.0, 1.0]`. Returns 0.0 if G1 has no edges.
17pub fn edge_conservation(
18    mapping: &[(usize, usize)],
19    adj1: &Array2<f64>,
20    adj2: &Array2<f64>,
21) -> f64 {
22    if mapping.is_empty() {
23        return 0.0;
24    }
25
26    let n1 = adj1.nrows();
27
28    // Build a lookup: g1_node -> g2_node
29    let mut g1_to_g2 = vec![usize::MAX; n1];
30    for &(i, j) in mapping {
31        if i < n1 {
32            g1_to_g2[i] = j;
33        }
34    }
35
36    let mut total_edges = 0u64;
37    let mut preserved_edges = 0u64;
38
39    // Count edges in G1's mapped subgraph and check preservation
40    for &(u, _) in mapping {
41        for &(v, _) in mapping {
42            if u >= v {
43                continue; // undirected: count each edge once
44            }
45            if u < adj1.nrows() && v < adj1.nrows() && adj1[[u, v]].abs() > f64::EPSILON {
46                total_edges += 1;
47                let fu = g1_to_g2[u];
48                let fv = g1_to_g2[v];
49                if fu < adj2.nrows() && fv < adj2.ncols() && adj2[[fu, fv]].abs() > f64::EPSILON {
50                    preserved_edges += 1;
51                }
52            }
53        }
54    }
55
56    if total_edges == 0 {
57        return 0.0;
58    }
59
60    preserved_edges as f64 / total_edges as f64
61}
62
63/// Symmetric Substructure Score (S3).
64///
65/// Computes: `|edges preserved| / (|edges in G1 subgraph| + |edges in G2 subgraph| - |edges preserved|)`
66///
67/// This metric penalizes both missing edges (in G1 not preserved) and extra edges
68/// (in G2 subgraph not corresponding to G1 edges), giving a more balanced view
69/// than edge conservation alone.
70///
71/// # Returns
72///
73/// A value in `[0.0, 1.0]`. Returns 0.0 if neither subgraph has edges.
74pub fn symmetric_substructure_score(
75    mapping: &[(usize, usize)],
76    adj1: &Array2<f64>,
77    adj2: &Array2<f64>,
78) -> f64 {
79    if mapping.is_empty() {
80        return 0.0;
81    }
82
83    let n1 = adj1.nrows();
84    let n2 = adj2.nrows();
85
86    // Build lookup tables
87    let mut g1_to_g2 = vec![usize::MAX; n1];
88    for &(i, j) in mapping {
89        if i < n1 {
90            g1_to_g2[i] = j;
91        }
92    }
93
94    // Collect the set of mapped G2 nodes
95    let mut mapped_g2: Vec<bool> = vec![false; n2];
96    for &(_, j) in mapping {
97        if j < n2 {
98            mapped_g2[j] = true;
99        }
100    }
101
102    // Count edges in G1's mapped subgraph
103    let mut edges_g1 = 0u64;
104    for &(u, _) in mapping {
105        for &(v, _) in mapping {
106            if u < v && u < adj1.nrows() && v < adj1.nrows() && adj1[[u, v]].abs() > f64::EPSILON {
107                edges_g1 += 1;
108            }
109        }
110    }
111
112    // Count edges in G2's mapped subgraph
113    let mut edges_g2 = 0u64;
114    for &(_, ju) in mapping {
115        for &(_, jv) in mapping {
116            if ju < jv
117                && ju < adj2.nrows()
118                && jv < adj2.nrows()
119                && adj2[[ju, jv]].abs() > f64::EPSILON
120            {
121                edges_g2 += 1;
122            }
123        }
124    }
125
126    // Count preserved edges
127    let mut preserved = 0u64;
128    for &(u, _) in mapping {
129        for &(v, _) in mapping {
130            if u < v && u < adj1.nrows() && v < adj1.nrows() && adj1[[u, v]].abs() > f64::EPSILON {
131                let fu = g1_to_g2[u];
132                let fv = g1_to_g2[v];
133                if fu < adj2.nrows() && fv < adj2.nrows() && adj2[[fu, fv]].abs() > f64::EPSILON {
134                    preserved += 1;
135                }
136            }
137        }
138    }
139
140    let denominator = edges_g1 + edges_g2 - preserved;
141    if denominator == 0 {
142        return 0.0;
143    }
144
145    preserved as f64 / denominator as f64
146}
147
148/// Node Correctness (NC): fraction of correctly mapped nodes.
149///
150/// Compares the computed mapping against a known ground truth alignment.
151/// A node is "correct" if it is mapped to the same target in both the
152/// computed and ground truth mappings.
153///
154/// # Returns
155///
156/// A value in `[0.0, 1.0]`. Returns 0.0 if ground truth is empty.
157pub fn node_correctness(mapping: &[(usize, usize)], ground_truth: &[(usize, usize)]) -> f64 {
158    if ground_truth.is_empty() {
159        return 0.0;
160    }
161
162    // Build lookup from ground truth
163    let max_node = ground_truth.iter().map(|&(i, _)| i).max().unwrap_or(0);
164    let mut gt_map = vec![usize::MAX; max_node + 1];
165    for &(i, j) in ground_truth {
166        if i <= max_node {
167            gt_map[i] = j;
168        }
169    }
170
171    let mut correct = 0usize;
172    for &(i, j) in mapping {
173        if i < gt_map.len() && gt_map[i] == j {
174            correct += 1;
175        }
176    }
177
178    correct as f64 / ground_truth.len() as f64
179}
180
181/// Induced Conserved Structure (ICS).
182///
183/// Computes: `|edges preserved| / |edges in mapped subgraph of G2|`
184///
185/// This metric measures how well the mapped subgraph of G2 is "covered"
186/// by edges from G1, penalizing extra edges in G2's subgraph that don't
187/// correspond to edges in G1.
188///
189/// # Returns
190///
191/// A value in `[0.0, 1.0]`. Returns 0.0 if the G2 subgraph has no edges.
192pub fn induced_conserved_structure(
193    mapping: &[(usize, usize)],
194    adj1: &Array2<f64>,
195    adj2: &Array2<f64>,
196) -> f64 {
197    if mapping.is_empty() {
198        return 0.0;
199    }
200
201    let n1 = adj1.nrows();
202
203    // Build lookup: g1_node -> g2_node
204    let mut g1_to_g2 = vec![usize::MAX; n1];
205    for &(i, j) in mapping {
206        if i < n1 {
207            g1_to_g2[i] = j;
208        }
209    }
210
211    // Count edges in G2's mapped subgraph
212    let mut edges_g2 = 0u64;
213    for &(_, ju) in mapping {
214        for &(_, jv) in mapping {
215            if ju < jv
216                && ju < adj2.nrows()
217                && jv < adj2.nrows()
218                && adj2[[ju, jv]].abs() > f64::EPSILON
219            {
220                edges_g2 += 1;
221            }
222        }
223    }
224
225    if edges_g2 == 0 {
226        return 0.0;
227    }
228
229    // Count preserved edges
230    let mut preserved = 0u64;
231    for &(u, _) in mapping {
232        for &(v, _) in mapping {
233            if u < v && u < adj1.nrows() && v < adj1.nrows() && adj1[[u, v]].abs() > f64::EPSILON {
234                let fu = g1_to_g2[u];
235                let fv = g1_to_g2[v];
236                if fu < adj2.nrows() && fv < adj2.nrows() && adj2[[fu, fv]].abs() > f64::EPSILON {
237                    preserved += 1;
238                }
239            }
240        }
241    }
242
243    preserved as f64 / edges_g2 as f64
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use scirs2_core::ndarray::Array2;
250
251    fn path_graph(n: usize) -> Array2<f64> {
252        let mut adj = Array2::zeros((n, n));
253        for i in 0..n.saturating_sub(1) {
254            adj[[i, i + 1]] = 1.0;
255            adj[[i + 1, i]] = 1.0;
256        }
257        adj
258    }
259
260    #[test]
261    fn test_ec_identity_mapping() {
262        let adj = path_graph(5);
263        let identity: Vec<(usize, usize)> = (0..5).map(|i| (i, i)).collect();
264        let ec = edge_conservation(&identity, &adj, &adj);
265        assert!(
266            (ec - 1.0).abs() < 1e-10,
267            "Identity mapping on same graph should have EC=1.0, got {}",
268            ec
269        );
270    }
271
272    #[test]
273    fn test_ec_random_worse_than_identity() {
274        let adj = path_graph(5);
275        let identity: Vec<(usize, usize)> = (0..5).map(|i| (i, i)).collect();
276        // Reversed mapping
277        let reversed: Vec<(usize, usize)> = (0..5).map(|i| (i, 4 - i)).collect();
278
279        let ec_id = edge_conservation(&identity, &adj, &adj);
280        let ec_rev = edge_conservation(&reversed, &adj, &adj);
281
282        // For a path graph, reversed mapping also preserves all edges
283        // (path is symmetric), so both should be 1.0
284        // But for asymmetric graphs, identity would be better
285        assert!(ec_id >= 0.0);
286        assert!(ec_rev >= 0.0);
287    }
288
289    #[test]
290    fn test_ec_empty_mapping() {
291        let adj = path_graph(3);
292        let ec = edge_conservation(&[], &adj, &adj);
293        assert!((ec).abs() < f64::EPSILON);
294    }
295
296    #[test]
297    fn test_ec_no_edges() {
298        let adj = Array2::zeros((3, 3));
299        let mapping: Vec<(usize, usize)> = vec![(0, 0), (1, 1), (2, 2)];
300        let ec = edge_conservation(&mapping, &adj, &adj);
301        assert!((ec).abs() < f64::EPSILON);
302    }
303
304    #[test]
305    fn test_s3_range() {
306        let adj = path_graph(5);
307        let mapping: Vec<(usize, usize)> = (0..5).map(|i| (i, i)).collect();
308        let s3 = symmetric_substructure_score(&mapping, &adj, &adj);
309        assert!(
310            (0.0..=1.0).contains(&s3),
311            "S3 should be in [0, 1], got {}",
312            s3
313        );
314    }
315
316    #[test]
317    fn test_s3_identity() {
318        let adj = path_graph(4);
319        let identity: Vec<(usize, usize)> = (0..4).map(|i| (i, i)).collect();
320        let s3 = symmetric_substructure_score(&identity, &adj, &adj);
321        assert!(
322            (s3 - 1.0).abs() < 1e-10,
323            "S3 of identity on same graph should be 1.0, got {}",
324            s3
325        );
326    }
327
328    #[test]
329    fn test_s3_empty() {
330        let adj = path_graph(3);
331        let s3 = symmetric_substructure_score(&[], &adj, &adj);
332        assert!((s3).abs() < f64::EPSILON);
333    }
334
335    #[test]
336    fn test_node_correctness_identity() {
337        let gt: Vec<(usize, usize)> = (0..5).map(|i| (i, i)).collect();
338        let mapping = gt.clone();
339        let nc = node_correctness(&mapping, &gt);
340        assert!((nc - 1.0).abs() < 1e-10);
341    }
342
343    #[test]
344    fn test_node_correctness_none_correct() {
345        let gt: Vec<(usize, usize)> = vec![(0, 0), (1, 1), (2, 2)];
346        let mapping: Vec<(usize, usize)> = vec![(0, 2), (1, 0), (2, 1)];
347        let nc = node_correctness(&mapping, &gt);
348        assert!((nc).abs() < f64::EPSILON);
349    }
350
351    #[test]
352    fn test_node_correctness_partial() {
353        let gt: Vec<(usize, usize)> = vec![(0, 0), (1, 1), (2, 2), (3, 3)];
354        let mapping: Vec<(usize, usize)> = vec![(0, 0), (1, 2), (2, 2), (3, 1)];
355        let nc = node_correctness(&mapping, &gt);
356        assert!((nc - 0.5).abs() < 1e-10); // 2 out of 4 correct
357    }
358
359    #[test]
360    fn test_node_correctness_empty_gt() {
361        let nc = node_correctness(&[(0, 0)], &[]);
362        assert!((nc).abs() < f64::EPSILON);
363    }
364
365    #[test]
366    fn test_ics_range() {
367        let adj = path_graph(5);
368        let mapping: Vec<(usize, usize)> = (0..5).map(|i| (i, i)).collect();
369        let ics = induced_conserved_structure(&mapping, &adj, &adj);
370        assert!(
371            (0.0..=1.0).contains(&ics),
372            "ICS should be in [0, 1], got {}",
373            ics
374        );
375    }
376
377    #[test]
378    fn test_ics_identity() {
379        let adj = path_graph(4);
380        let identity: Vec<(usize, usize)> = (0..4).map(|i| (i, i)).collect();
381        let ics = induced_conserved_structure(&identity, &adj, &adj);
382        assert!(
383            (ics - 1.0).abs() < 1e-10,
384            "ICS of identity on same graph should be 1.0, got {}",
385            ics
386        );
387    }
388
389    #[test]
390    fn test_ics_empty() {
391        let adj = path_graph(3);
392        let ics = induced_conserved_structure(&[], &adj, &adj);
393        assert!((ics).abs() < f64::EPSILON);
394    }
395}