From 004dc165683b853b23f4f936d31b206a873ee43e Mon Sep 17 00:00:00 2001 From: Tapani Honkanen Date: Thu, 9 Mar 2023 15:32:09 +0200 Subject: [PATCH] Add edge betweenness centrality (#799) * Add edge betweenness centrality * Fix MSRV * Update pyfunction signatures * Add tests * Add test with stable graph * Remove unnecessary import from doc test * Address review comments --------- Co-authored-by: Ivan Carvalho <8753214+IvanIsCoding@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- Cargo.lock | 21 ++ docs/source/api.rst | 3 + ...tweenness-centrality-8de06bf716caece0.yaml | 39 +++ rustworkx-core/Cargo.toml | 1 + rustworkx-core/src/centrality.rs | 316 ++++++++++++++++++ rustworkx/__init__.py | 65 ++++ src/centrality.rs | 134 +++++++- src/iterators.rs | 21 ++ src/lib.rs | 3 + .../digraph/test_centrality.py | 35 ++ .../rustworkx_tests/graph/test_centrality.py | 57 ++++ 11 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/add-edge-betweenness-centrality-8de06bf716caece0.yaml diff --git a/Cargo.lock b/Cargo.lock index fc76eb990..47916fbbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.4" @@ -479,6 +488,17 @@ dependencies = [ "rayon-core", ] +[[package]] +name = "rayon-cond" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac2a28c5317e6d26ac87a8629c0eb362690ed1d739f4040e21cfaafdf04e6f8" +dependencies = [ + "either", + "itertools", + "rayon", +] + [[package]] name = "rayon-core" version = "1.10.1" @@ -543,6 +563,7 @@ dependencies = [ "priority-queue", "rand", "rayon", + "rayon-cond", ] [[package]] diff --git a/docs/source/api.rst b/docs/source/api.rst index 8368188c2..ad5f6d975 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -320,6 +320,7 @@ the functions from the explicitly typed based on the data type. rustworkx.digraph_spring_layout rustworkx.digraph_num_shortest_paths_unweighted rustworkx.digraph_betweenness_centrality + rustworkx.digraph_edge_betweenness_centrality rustworkx.digraph_eigenvector_centrality rustworkx.digraph_unweighted_average_shortest_path_length rustworkx.digraph_bfs_search @@ -374,6 +375,7 @@ typed API based on the data type. rustworkx.graph_spring_layout rustworkx.graph_num_shortest_paths_unweighted rustworkx.graph_betweenness_centrality + rustworkx.graph_edge_betweenness_centrality rustworkx.graph_eigenvector_centrality rustworkx.graph_unweighted_average_shortest_path_length rustworkx.graph_bfs_search @@ -416,6 +418,7 @@ Custom Return Types rustworkx.AllPairsPathMapping rustworkx.AllPairsPathLengthMapping rustworkx.CentralityMapping + rustworkx.EdgeCentralityMapping rustworkx.Chains rustworkx.NodeMap rustworkx.ProductNodeMap diff --git a/releasenotes/notes/add-edge-betweenness-centrality-8de06bf716caece0.yaml b/releasenotes/notes/add-edge-betweenness-centrality-8de06bf716caece0.yaml new file mode 100644 index 000000000..81a002d1c --- /dev/null +++ b/releasenotes/notes/add-edge-betweenness-centrality-8de06bf716caece0.yaml @@ -0,0 +1,39 @@ +--- +features: + - | + Added a new function, :func:`~rustworkx.edge_betweenness_centrality` to compute + edge betweenness centrality of all edges in a :class:`~rustworkx.PyGraph` or + :class:`~rustworkx.PyDiGraph` object. The algorithm used in this function is + based on: + Ulrik Brandes, On Variants of Shortest-Path Betweenness Centrality + and their Generic Computation. Social Networks 30(2):136-145, 2008. + Edge betweenness centrality of an edge :math:`e` is the sum of the + fraction of all-pairs shortest paths that pass through :math`e` + + .. math:: + + c_B(e) =\sum_{s,t \in V} \frac{\sigma(s, t|e)}{\sigma(s, t)} + + where :math:`V` is the set of nodes, :math:`\sigma(s, t)` is the + number of shortest :math:`(s, t)`-paths, and :math:`\sigma(s, t|e)` is + the number of those paths passing through edge :math:`e`. + For example, the following computes the edge betweenness centrality for all + edges in a 5x5 grid graph and uses the result to color the edges in a graph + visualization: + + .. jupyter-execute:: + + import rustworkx + from rustworkx.visualization import mpl_draw + + graph = rustworkx.generators.grid_graph(5, 5) + btw = rustworkx.edge_betweenness_centrality(graph) + # Color edges in graph visualization with edge betweenness centrality + colors = [] + for i in graph.edge_indices(): + colors.append(btw[i]) + mpl_draw(graph, edge_color=colors) + - | + Added a new function to rustworkx-core ``edge_betweenness_centrality`` to + the ``rustworkx_core:centrality`` module which computes the edge betweenness + centrality of all edges in a given graph. diff --git a/rustworkx-core/Cargo.toml b/rustworkx-core/Cargo.toml index 0b933b2f6..7a3762a02 100644 --- a/rustworkx-core/Cargo.toml +++ b/rustworkx-core/Cargo.toml @@ -17,6 +17,7 @@ petgraph = "0.6.3" rayon = "1.6" num-traits = "0.2" priority-queue = "1.2" +rayon-cond = "0.2" [dependencies.hashbrown] version = "0.12" diff --git a/rustworkx-core/src/centrality.rs b/rustworkx-core/src/centrality.rs index f76a80a9f..14d5cd4a6 100644 --- a/rustworkx-core/src/centrality.rs +++ b/rustworkx-core/src/centrality.rs @@ -16,6 +16,8 @@ use std::sync::RwLock; use hashbrown::HashMap; use petgraph::visit::{ + EdgeCount, + EdgeIndexable, EdgeRef, GraphBase, GraphProp, // allows is_directed @@ -27,6 +29,7 @@ use petgraph::visit::{ NodeIndexable, }; use rayon::prelude::*; +use rayon_cond::CondIterator; /// Compute the betweenness centrality of all nodes in a graph. /// @@ -68,6 +71,8 @@ use rayon::prelude::*; /// output /// ); /// ``` +/// # See Also +/// [`edge_betweenness_centrality`] pub fn betweenness_centrality( graph: G, endpoints: bool, @@ -178,6 +183,93 @@ where betweenness } +/// Compute the edge betweenness centrality of all edges in a graph. +/// +/// The algorithm used in this function is based on: +/// +/// Ulrik Brandes: On Variants of Shortest-Path Betweenness +/// Centrality and their Generic Computation. +/// Social Networks 30(2):136-145, 2008. +/// https://doi.org/10.1016/j.socnet.2007.11.001. +/// +/// This function is multithreaded and will run in parallel if the number +/// of nodes in the graph is above the value of ``parallel_threshold``. If the +/// function will be running in parallel the env var ``RAYON_NUM_THREADS`` can +/// be used to adjust how many threads will be used. +/// +/// Arguments: +/// +/// * `graph` - The graph object to run the algorithm on +/// * `normalized` - Whether to normalize the betweenness scores by the number +/// of distinct paths between all pairs of nodes +/// * `parallel_threshold` - The number of nodes to calculate the betweenness +/// centrality in parallel at, if the number of nodes in `graph` is less +/// than this value it will run in a single thread. A good default to use +/// here if you're not sure is `50` as that was found to be roughly the +/// number of nodes where parallelism improves performance +/// +/// # Example +/// ```rust +/// use rustworkx_core::petgraph; +/// use rustworkx_core::centrality::edge_betweenness_centrality; +/// +/// let g = petgraph::graph::UnGraph::::from_edges(&[ +/// (0, 4), (1, 2), (1, 3), (2, 3), (3, 4), (1, 4) +/// ]); +/// +/// let output = edge_betweenness_centrality(&g, false, 200); +/// let expected = vec![Some(4.0), Some(2.0), Some(1.0), Some(2.0), Some(3.0), Some(3.0)]; +/// assert_eq!(output, expected); +/// ``` +/// # See Also +/// [`betweenness_centrality`] +pub fn edge_betweenness_centrality( + graph: G, + normalized: bool, + parallel_threshold: usize, +) -> Vec> +where + G: NodeIndexable + + EdgeIndexable + + IntoEdges + + IntoNodeIdentifiers + + IntoNeighborsDirected + + NodeCount + + EdgeCount + + GraphProp + + Sync, + G::NodeId: Eq + Hash + Send, + G::EdgeId: Eq + Hash + Send, +{ + let max_index = graph.node_bound(); + let mut betweenness = vec![None; graph.edge_bound()]; + for edge in graph.edge_references() { + let is: usize = EdgeIndexable::to_index(&graph, edge.id()); + betweenness[is] = Some(0.0); + } + let locked_betweenness = RwLock::new(&mut betweenness); + let node_indices: Vec = graph.node_identifiers().collect(); + CondIterator::new(node_indices, graph.node_count() >= parallel_threshold) + .map(|node_s| shortest_path_for_edge_centrality(&graph, &node_s)) + .for_each(|mut shortest_path_calc| { + accumulate_edges( + &locked_betweenness, + max_index, + &mut shortest_path_calc, + &graph, + ); + }); + + _rescale( + &mut betweenness, + graph.node_count(), + normalized, + graph.is_directed(), + true, + ); + betweenness +} + fn _rescale( betweenness: &mut [Option], node_count: usize, @@ -283,6 +375,34 @@ fn _accumulate_endpoints( } } +fn accumulate_edges( + locked_betweenness: &RwLock<&mut Vec>>, + max_index: usize, + path_calc: &mut ShortestPathDataWithEdges, + graph: G, +) where + G: NodeIndexable + EdgeIndexable + Sync, + G::NodeId: Eq + Hash, + G::EdgeId: Eq + Hash, +{ + let mut delta = vec![0.0; max_index]; + for w in &path_calc.verts_sorted_by_distance { + let iw = NodeIndexable::to_index(&graph, *w); + let coeff = (1.0 + delta[iw]) / path_calc.sigma[w]; + let p_w = path_calc.predecessors.get(w).unwrap(); + let e_w = path_calc.predecessor_edges.get(w).unwrap(); + let mut betweenness = locked_betweenness.write().unwrap(); + for i in 0..p_w.len() { + let v = p_w[i]; + let iv = NodeIndexable::to_index(&graph, v); + let ie = EdgeIndexable::to_index(&graph, e_w[i]); + let c = path_calc.sigma[&v] * coeff; + betweenness[ie] = betweenness[ie].map(|x| x + c); + delta[iv] += c; + } + } +} + struct ShortestPathData where G: GraphBase, @@ -337,6 +457,202 @@ where } } +struct ShortestPathDataWithEdges +where + G: GraphBase, + G::NodeId: Eq + Hash, + G::EdgeId: Eq + Hash, +{ + verts_sorted_by_distance: Vec, + predecessors: HashMap>, + predecessor_edges: HashMap>, + sigma: HashMap, +} + +fn shortest_path_for_edge_centrality( + graph: G, + node_s: &G::NodeId, +) -> ShortestPathDataWithEdges +where + G: NodeIndexable + + IntoNodeIdentifiers + + IntoNeighborsDirected + + NodeCount + + GraphBase + + IntoEdges, + G::NodeId: Eq + Hash, + G::EdgeId: Eq + Hash, +{ + let mut verts_sorted_by_distance: Vec = Vec::new(); // a stack + let c = graph.node_count(); + let mut predecessors = HashMap::>::with_capacity(c); + let mut predecessor_edges = HashMap::>::with_capacity(c); + let mut sigma = HashMap::::with_capacity(c); + let mut distance = HashMap::::with_capacity(c); + #[allow(non_snake_case)] + let mut Q: VecDeque = VecDeque::with_capacity(c); + + for node in graph.node_identifiers() { + predecessors.insert(node, Vec::new()); + predecessor_edges.insert(node, Vec::new()); + sigma.insert(node, 0.0); + distance.insert(node, -1); + } + sigma.insert(*node_s, 1.0); + distance.insert(*node_s, 0); + Q.push_back(*node_s); + while let Some(v) = Q.pop_front() { + verts_sorted_by_distance.push(v); + let distance_v = distance[&v]; + for edge in graph.edges(v) { + let w = edge.target(); + if distance[&w] < 0 { + Q.push_back(w); + distance.insert(w, distance_v + 1); + } + if distance[&w] == distance_v + 1 { + sigma.insert(w, sigma[&w] + sigma[&v]); + let e_p = predecessors.get_mut(&w).unwrap(); + e_p.push(v); + predecessor_edges.get_mut(&w).unwrap().push(edge.id()); + } + } + } + verts_sorted_by_distance.reverse(); // will be effectively popping from the stack + ShortestPathDataWithEdges { + verts_sorted_by_distance, + predecessors, + predecessor_edges, + sigma, + } +} + +#[cfg(test)] +mod test_edge_betweenness_centrality { + use crate::centrality::edge_betweenness_centrality; + use petgraph::graph::edge_index; + use petgraph::prelude::StableGraph; + use petgraph::Undirected; + + macro_rules! assert_almost_equal { + ($x:expr, $y:expr, $d:expr) => { + if ($x - $y).abs() >= $d { + panic!("{} != {} within delta of {}", $x, $y, $d); + } + }; + } + + #[test] + fn test_undirected_graph_normalized() { + let graph = petgraph::graph::UnGraph::<(), ()>::from_edges(&[ + (0, 6), + (0, 4), + (0, 1), + (0, 5), + (1, 6), + (1, 7), + (1, 3), + (1, 4), + (2, 6), + (2, 3), + (3, 5), + (3, 7), + (3, 6), + (4, 5), + (5, 6), + ]); + let output = edge_betweenness_centrality(&graph, true, 50); + let result = output.iter().map(|x| x.unwrap()).collect::>(); + let expected_values = vec![ + 0.1023809, 0.0547619, 0.0922619, 0.05654762, 0.09940476, 0.125, 0.09940476, 0.12440476, + 0.12857143, 0.12142857, 0.13511905, 0.125, 0.06547619, 0.08869048, 0.08154762, + ]; + for i in 0..15 { + assert_almost_equal!(result[i], expected_values[i], 1e-4); + } + } + + #[test] + fn test_undirected_graph_unnormalized() { + let graph = petgraph::graph::UnGraph::<(), ()>::from_edges(&[ + (0, 2), + (0, 4), + (0, 1), + (1, 3), + (1, 5), + (1, 7), + (2, 7), + (2, 3), + (3, 5), + (3, 6), + (4, 6), + (5, 7), + ]); + let output = edge_betweenness_centrality(&graph, false, 50); + let result = output.iter().map(|x| x.unwrap()).collect::>(); + let expected_values = vec![ + 3.83333, 5.5, 5.33333, 3.5, 2.5, 3.0, 3.5, 4.0, 3.66667, 6.5, 3.5, 2.16667, + ]; + for i in 0..12 { + assert_almost_equal!(result[i], expected_values[i], 1e-4); + } + } + + #[test] + fn test_directed_graph_normalized() { + let graph = petgraph::graph::DiGraph::<(), ()>::from_edges(&[ + (0, 1), + (1, 0), + (1, 3), + (1, 2), + (1, 4), + (2, 3), + (2, 4), + (2, 1), + (3, 2), + (4, 3), + ]); + let output = edge_betweenness_centrality(&graph, true, 50); + let result = output.iter().map(|x| x.unwrap()).collect::>(); + let expected_values = vec![0.2, 0.2, 0.1, 0.1, 0.1, 0.05, 0.1, 0.3, 0.35, 0.2]; + for i in 0..10 { + assert_almost_equal!(result[i], expected_values[i], 1e-4); + } + } + + #[test] + fn test_directed_graph_unnormalized() { + let graph = petgraph::graph::DiGraph::<(), ()>::from_edges(&[ + (0, 4), + (1, 0), + (1, 3), + (2, 3), + (2, 4), + (2, 0), + (3, 4), + (3, 2), + (3, 1), + (4, 1), + ]); + let output = edge_betweenness_centrality(&graph, false, 50); + let result = output.iter().map(|x| x.unwrap()).collect::>(); + let expected_values = vec![4.5, 3.0, 6.5, 1.5, 1.5, 1.5, 1.5, 4.5, 2.0, 7.5]; + for i in 0..10 { + assert_almost_equal!(result[i], expected_values[i], 1e-4); + } + } + + #[test] + fn test_stable_graph_with_removed_edges() { + let mut graph: StableGraph<(), (), Undirected> = + StableGraph::from_edges(&[(0, 1), (1, 2), (2, 3), (3, 0)]); + graph.remove_edge(edge_index(1)); + let result = edge_betweenness_centrality(&graph, false, 50); + let expected_values = vec![Some(3.0), None, Some(3.0), Some(4.0)]; + assert_eq!(result, expected_values); + } +} + /// Compute the eigenvector centrality of a graph /// /// For details on the eigenvector centrality refer to: diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index 6d82d41f2..c6104ce4e 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -1560,6 +1560,10 @@ def betweenness_centrality(graph, normalized=True, endpoints=False, parallel_thr defaults to 50). If the function will be running in parallel the env var ``RAYON_NUM_THREADS`` can be used to adjust how many threads will be used. + See Also + -------- + edge_betweenness_centrality + :param PyDiGraph graph: The input graph :param bool normalized: Whether to normalize the betweenness scores by the number of distinct paths between all pairs of nodes. @@ -1596,6 +1600,67 @@ def _graph_betweenness_centrality(graph, normalized=True, endpoints=False, paral ) +@functools.singledispatch +def edge_betweenness_centrality(graph, normalized=True, parallel_threshold=50): + """Compute the edge betweenness centrality of all edges in a graph. + + Edge betweenness centrality of an edge :math:`e` is the sum of the + fraction of all-pairs shortest paths that pass through :math`e` + + .. math:: + + c_B(e) = \sum_{s,t \in V} \frac{\sigma(s, t|e)}{\sigma(s, t)} + + where :math:`V` is the set of nodes, :math:`\sigma(s, t)` is the + number of shortest :math:`(s, t)`-paths, and :math:`\sigma(s, t|e)` is + the number of those paths passing through edge :math:`e`. + + The above definition and the algorithm used in this function is based on: + + Ulrik Brandes, On Variants of Shortest-Path Betweenness Centrality + and their Generic Computation. Social Networks 30(2):136-145, 2008. + + This function is multithreaded and will run in parallel if the number + of nodes in the graph is above the value of ``parallel_threshold`` (it + defaults to 50). If the function will be running in parallel the env var + ``RAYON_NUM_THREADS`` can be used to adjust how many threads will be used. + + See Also + -------- + betweenness_centrality + + :param PyGraph graph: The input graph + :param bool normalized: Whether to normalize the betweenness scores by the + number of distinct paths between all pairs of nodes. + :param int parallel_threshold: The number of nodes to calculate + the edge betweenness centrality in parallel at if the number of nodes in + the graph is less than this value it will run in a single thread. The + default value is 50 + + :returns: a read-only dict-like object whose keys are edges and values are the + betweenness score for each node. + :rtype: EdgeCentralityMapping + """ + + +@edge_betweenness_centrality.register(PyDiGraph) +def _digraph_edge_betweenness_centrality(graph, normalized=True, parallel_threshold=50): + return digraph_edge_betweenness_centrality( + graph, + normalized=normalized, + parallel_threshold=parallel_threshold, + ) + + +@edge_betweenness_centrality.register(PyGraph) +def _graph_edge_betweenness_centrality(graph, normalized=True, parallel_threshold=50): + return graph_edge_betweenness_centrality( + graph, + normalized=normalized, + parallel_threshold=parallel_threshold, + ) + + @functools.singledispatch def eigenvector_centrality(graph, weight_fn=None, default_weight=1.0, max_iter=100, tol=1e-6): """Compute the eigenvector centrality of a graph. diff --git a/src/centrality.rs b/src/centrality.rs index 1168096ea..245494438 100644 --- a/src/centrality.rs +++ b/src/centrality.rs @@ -14,7 +14,7 @@ use std::convert::TryFrom; use crate::digraph; use crate::graph; -use crate::iterators::CentralityMapping; +use crate::iterators::{CentralityMapping, EdgeCentralityMapping}; use crate::CostFn; use crate::FailedToConverge; @@ -49,6 +49,10 @@ use rustworkx_core::centrality; /// defaults to 50). If the function will be running in parallel the env var /// ``RAYON_NUM_THREADS`` can be used to adjust how many threads will be used. /// +/// See Also +/// -------- +/// graph_edge_betweenness_centrality +/// /// :param PyGraph graph: The input graph /// :param bool normalized: Whether to normalize the betweenness scores by the number of distinct /// paths between all pairs of nodes. @@ -113,6 +117,10 @@ pub fn graph_betweenness_centrality( /// defaults to 50). If the function will be running in parallel the env var /// ``RAYON_NUM_THREADS`` can be used to adjust how many threads will be used. /// +/// See Also +/// -------- +/// digraph_edge_betweenness_centrality +/// /// :param PyDiGraph graph: The input graph /// :param bool normalized: Whether to normalize the betweenness scores by the number of distinct /// paths between all pairs of nodes. @@ -152,6 +160,130 @@ pub fn digraph_betweenness_centrality( } } +/// Compute the edge betweenness centrality of all edges in a :class:`~PyGraph`. +/// +/// Edge betweenness centrality of an edge :math:`e` is the sum of the +/// fraction of all-pairs shortest paths that pass through :math`e` +/// +/// .. math:: +/// +/// c_B(e) =\sum_{s,t \in V} \frac{\sigma(s, t|e)}{\sigma(s, t)} +/// +/// where :math:`V` is the set of nodes, :math:`\sigma(s, t)` is the +/// number of shortest :math:`(s, t)`-paths, and :math:`\sigma(s, t|e)` is +/// the number of those paths passing through edge :math:`e`. +/// +/// The above definition and the algorithm used in this function is based on: +/// +/// Ulrik Brandes, On Variants of Shortest-Path Betweenness Centrality +/// and their Generic Computation. Social Networks 30(2):136-145, 2008. +/// +/// This function is multithreaded and will run in parallel if the number +/// of nodes in the graph is above the value of ``parallel_threshold`` (it +/// defaults to 50). If the function will be running in parallel the env var +/// ``RAYON_NUM_THREADS`` can be used to adjust how many threads will be used. +/// +/// See Also +/// -------- +/// graph_betweenness_centrality +/// +/// :param PyGraph graph: The input graph +/// :param bool normalized: Whether to normalize the betweenness scores by the number of distinct +/// paths between all pairs of nodes. +/// :param int parallel_threshold: The number of nodes to calculate the +/// the betweenness centrality in parallel at if the number of nodes in +/// the graph is less than this value it will run in a single thread. The +/// default value is 50 +/// +/// :returns: a read-only dict-like object whose keys are the edge indices and values are the +/// betweenness score for each edge. +/// :rtype: EdgeCentralityMapping +#[pyfunction( + signature = ( + graph, + normalized=true, + parallel_threshold=50 + ) +)] +#[pyo3(text_signature = "(graph, /, normalized=True, parallel_threshold=50)")] +pub fn graph_edge_betweenness_centrality( + graph: &graph::PyGraph, + normalized: bool, + parallel_threshold: usize, +) -> PyResult { + let betweenness = + centrality::edge_betweenness_centrality(&graph.graph, normalized, parallel_threshold); + Ok(EdgeCentralityMapping { + centralities: betweenness + .into_iter() + .enumerate() + .filter_map(|(i, v)| v.map(|x| (i, x))) + .collect(), + }) +} + +/// Compute the edge betweenness centrality of all edges in a :class:`~PyDiGraph`. +/// +/// Edge betweenness centrality of an edge :math:`e` is the sum of the +/// fraction of all-pairs shortest paths that pass through :math`e` +/// +/// .. math:: +/// +/// c_B(e) =\sum_{s,t \in V} \frac{\sigma(s, t|e)}{\sigma(s, t)} +/// +/// where :math:`V` is the set of nodes, :math:`\sigma(s, t)` is the +/// number of shortest :math:`(s, t)`-paths, and :math:`\sigma(s, t|e)` is +/// the number of those paths passing through edge :math:`e`. +/// +/// The above definition and the algorithm used in this function is based on: +/// +/// Ulrik Brandes, On Variants of Shortest-Path Betweenness Centrality +/// and their Generic Computation. Social Networks 30(2):136-145, 2008. +/// +/// This function is multithreaded and will run in parallel if the number +/// of nodes in the graph is above the value of ``parallel_threshold`` (it +/// defaults to 50). If the function will be running in parallel the env var +/// ``RAYON_NUM_THREADS`` can be used to adjust how many threads will be used. +/// +/// See Also +/// -------- +/// digraph_betweenness_centrality +/// +/// :param PyGraph graph: The input graph +/// :param bool normalized: Whether to normalize the betweenness scores by the number of distinct +/// paths between all pairs of nodes. +/// :param int parallel_threshold: The number of nodes to calculate the +/// the betweenness centrality in parallel at if the number of nodes in +/// the graph is less than this value it will run in a single thread. The +/// default value is 50 +/// +/// :returns: a read-only dict-like object whose keys are edges and values are the +/// betweenness score for each node. +/// :rtype: EdgeCentralityMapping +#[pyfunction( + signature = ( + graph, + normalized=true, + parallel_threshold=50 + ) +)] +#[pyo3(text_signature = "(graph, /, normalized=True, parallel_threshold=50)")] +pub fn digraph_edge_betweenness_centrality( + graph: &digraph::PyDiGraph, + normalized: bool, + parallel_threshold: usize, +) -> PyResult { + let betweenness = + centrality::edge_betweenness_centrality(&graph.graph, normalized, parallel_threshold); + Ok(EdgeCentralityMapping { + centralities: betweenness + .into_iter() + .enumerate() + .filter_map(|(i, v)| v.map(|x| (i, x))) + .collect(), + }) +} + /// Compute the eigenvector centrality of a :class:`~PyGraph`. /// /// For details on the eigenvector centrality refer to: diff --git a/src/iterators.rs b/src/iterators.rs index b6e496cc5..33e0cad8b 100644 --- a/src/iterators.rs +++ b/src/iterators.rs @@ -1466,6 +1466,27 @@ custom_hash_map_iter_impl!( ); impl PyGCProtocol for CentralityMapping {} +custom_hash_map_iter_impl!( + EdgeCentralityMapping, + EdgeCentralityMappingKeys, + EdgeCentralityMappingValues, + EdgeCentralityMappingItems, + centralities, + centralities_keys, + centralities_values, + centralities_items, + usize, + f64, + "A custom class for the return of edge centralities at target edges + + This class is a container class for the results of functions that + return a mapping of integer edge indices to the float betweenness score for + that edge. It implements the Python mapping protocol so you can treat the + return as a read-only mapping/dict. + " +); +impl PyGCProtocol for EdgeCentralityMapping {} + custom_hash_map_iter_impl!( NodesCountMapping, NodesCountMappingKeys, diff --git a/src/lib.rs b/src/lib.rs index a9ad4f4dd..c086afec7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -414,6 +414,8 @@ fn rustworkx(py: Python<'_>, m: &PyModule) -> PyResult<()> { ))?; m.add_wrapped(wrap_pyfunction!(graph_betweenness_centrality))?; m.add_wrapped(wrap_pyfunction!(digraph_betweenness_centrality))?; + m.add_wrapped(wrap_pyfunction!(graph_edge_betweenness_centrality))?; + m.add_wrapped(wrap_pyfunction!(digraph_edge_betweenness_centrality))?; m.add_wrapped(wrap_pyfunction!(graph_eigenvector_centrality))?; m.add_wrapped(wrap_pyfunction!(digraph_eigenvector_centrality))?; m.add_wrapped(wrap_pyfunction!(graph_astar_shortest_path))?; @@ -490,6 +492,7 @@ fn rustworkx(py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/tests/rustworkx_tests/digraph/test_centrality.py b/tests/rustworkx_tests/digraph/test_centrality.py index 4a739b0bf..071890eba 100644 --- a/tests/rustworkx_tests/digraph/test_centrality.py +++ b/tests/rustworkx_tests/digraph/test_centrality.py @@ -150,3 +150,38 @@ def test_no_convergence(self): graph = rustworkx.PyDiGraph() with self.assertRaises(rustworkx.FailedToConverge): rustworkx.eigenvector_centrality(graph, max_iter=0) + + +class TestEdgeBetweennessCentrality(unittest.TestCase): + def test_complete_graph(self): + graph = rustworkx.generators.directed_mesh_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph) + for value in centrality.values(): + self.assertAlmostEqual(value, 0.05) + + def test_path_graph(self): + graph = rustworkx.generators.directed_path_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph) + expected = {0: 0.2, 1: 0.3, 2: 0.3, 3: 0.2} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_cycle_graph(self): + graph = rustworkx.generators.directed_cycle_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph) + for k, v in centrality.items(): + self.assertAlmostEqual(v, 0.5) + + def test_tree_unnormalized(self): + graph = rustworkx.generators.full_rary_tree(2, 7).to_directed() + centrality = rustworkx.edge_betweenness_centrality(graph, normalized=False) + expected = {0: 12, 1: 12, 2: 12, 3: 12, 4: 6, 5: 6, 6: 6, 7: 6, 8: 6, 9: 6, 10: 6, 11: 6} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_path_graph_unnormalized(self): + graph = rustworkx.generators.directed_path_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph, normalized=False) + expected = {0: 4.0, 1: 6.0, 2: 6.0, 3: 4.0} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) diff --git a/tests/rustworkx_tests/graph/test_centrality.py b/tests/rustworkx_tests/graph/test_centrality.py index ff5559e2a..340c84247 100644 --- a/tests/rustworkx_tests/graph/test_centrality.py +++ b/tests/rustworkx_tests/graph/test_centrality.py @@ -121,3 +121,60 @@ def test_no_convergence(self): graph = rustworkx.PyGraph() with self.assertRaises(rustworkx.FailedToConverge): rustworkx.eigenvector_centrality(graph, max_iter=0) + + +class TestEdgeBetweennessCentrality(unittest.TestCase): + def test_complete_graph(self): + graph = rustworkx.generators.mesh_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph) + for value in centrality.values(): + self.assertAlmostEqual(value, 0.1) + + def test_path_graph(self): + graph = rustworkx.generators.path_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph) + expected = {0: 0.4, 1: 0.6, 2: 0.6, 3: 0.4} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_cycle_graph(self): + graph = rustworkx.generators.cycle_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph) + for k, v in centrality.items(): + self.assertAlmostEqual(v, 0.3) + + def test_tree_unnormalized(self): + graph = rustworkx.generators.full_rary_tree(2, 7) + centrality = rustworkx.edge_betweenness_centrality(graph, normalized=False) + expected = {0: 12.0, 1: 12.0, 2: 6.0, 3: 6.0, 4: 6.0, 5: 6.0} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_path_graph_unnormalized(self): + graph = rustworkx.generators.path_graph(5) + centrality = rustworkx.edge_betweenness_centrality(graph, normalized=False) + expected = {0: 4.0, 1: 6.0, 2: 6.0, 3: 4.0} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k]) + + def test_custom_graph_unnormalized(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(10)) + graph.add_edges_from( + [ + (0, 1, 1), + (0, 2, 1), + (0, 3, 1), + (0, 4, 1), + (3, 5, 1), + (4, 6, 1), + (5, 7, 1), + (6, 8, 1), + (7, 8, 1), + (8, 9, 1), + ] + ) + centrality = rustworkx.edge_betweenness_centrality(graph, normalized=False) + expected = {0: 9, 1: 9, 2: 12, 3: 15, 4: 11, 5: 14, 6: 10, 7: 13, 8: 9, 9: 9} + for k, v in centrality.items(): + self.assertAlmostEqual(v, expected[k])