diff --git a/docs/source/api.rst b/docs/source/api.rst index ad5f6d975..9d8010133 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -48,13 +48,15 @@ Shortest Paths .. _centrality: Centrality --------------- +---------- .. autosummary:: :toctree: apiref rustworkx.betweenness_centrality + rustworkx.edge_betweenness_centrality rustworkx.eigenvector_centrality + rustworkx.closeness_centrality .. _traversal: @@ -321,6 +323,7 @@ the functions from the explicitly typed based on the data type. rustworkx.digraph_num_shortest_paths_unweighted rustworkx.digraph_betweenness_centrality rustworkx.digraph_edge_betweenness_centrality + rustworkx.digraph_closeness_centrality rustworkx.digraph_eigenvector_centrality rustworkx.digraph_unweighted_average_shortest_path_length rustworkx.digraph_bfs_search @@ -376,6 +379,7 @@ typed API based on the data type. rustworkx.graph_num_shortest_paths_unweighted rustworkx.graph_betweenness_centrality rustworkx.graph_edge_betweenness_centrality + rustworkx.graph_closeness_centrality rustworkx.graph_eigenvector_centrality rustworkx.graph_unweighted_average_shortest_path_length rustworkx.graph_bfs_search diff --git a/releasenotes/notes/closeness-centrality-459c5c7e35cb2e63.yaml b/releasenotes/notes/closeness-centrality-459c5c7e35cb2e63.yaml new file mode 100644 index 000000000..6252c21ce --- /dev/null +++ b/releasenotes/notes/closeness-centrality-459c5c7e35cb2e63.yaml @@ -0,0 +1,42 @@ +--- +features: + - | + Added a new function, :func:`~.closeness_centrality` to compute the + closeness centrality of all nodes in a :class:`~.PyGraph` or + :class:`~.PyDiGraph` object. + + The closeness centrality of a node :math:`u` is defined as the the + reciprocal of the average shortest path distance to :math:`u` over all + :math:`n-1` reachable nodes. In it's general form this can be expressed as: + + .. math:: + + C(u) = \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, + + where :math:`d(v, u)` is the shortest-path distance between :math:`v` and + :math:`u`, and :math:`n` is the number of nodes that can reach :math:`u`. + For example, to visualize the closeness centrality of a graph: + + .. jupyter-execute:: + + import matplotlib.pyplot as plt + + import rustworkx as rx + from rustworkx.visualization import mpl_draw + + graph = rx.generators.hexagonal_lattice_graph(4, 4) + centrality = rx.closeness_centrality(graph) + # Generate a color list + colors = [] + for node in graph.node_indices(): + colors.append(centrality[node]) + # Generate a visualization with a colorbar + plt.rcParams['figure.figsize'] = [15, 10] + ax = plt.gca() + sm = plt.cm.ScalarMappable(norm=plt.Normalize( + vmin=min(centrality.values()), + vmax=max(centrality.values()) + )) + plt.colorbar(sm, ax=ax) + plt.title("Closeness Centrality of a 4 x 4 Hexagonal Lattice Graph") + mpl_draw(graph, node_color=colors, ax=ax) diff --git a/rustworkx-core/src/centrality.rs b/rustworkx-core/src/centrality.rs index 14d5cd4a6..a816af427 100644 --- a/rustworkx-core/src/centrality.rs +++ b/rustworkx-core/src/centrality.rs @@ -15,6 +15,7 @@ use std::hash::Hash; use std::sync::RwLock; use hashbrown::HashMap; +use petgraph::algo::dijkstra; use petgraph::visit::{ EdgeCount, EdgeIndexable, @@ -22,11 +23,14 @@ use petgraph::visit::{ GraphBase, GraphProp, // allows is_directed IntoEdges, + IntoEdgesDirected, IntoNeighbors, IntoNeighborsDirected, IntoNodeIdentifiers, NodeCount, NodeIndexable, + Reversed, + Visitable, }; use rayon::prelude::*; use rayon_cond::CondIterator; @@ -190,7 +194,7 @@ where /// 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 @@ -828,3 +832,79 @@ mod test_eigenvector_centrality { } } } + +/// Compute the closeness centrality of each node in the graph. +/// +/// The closeness centrality of a node `u` is the reciprocal of the average +/// shortest path distance to `u` over all `n-1` reachable nodes. +/// +/// In the case of a graphs with more than one connected component there is +/// an alternative improved formula that calculates the closeness centrality +/// as "a ratio of the fraction of actors in the group who are reachable, to +/// the average distance" [^WF]. You can enable this by setting `wf_improved` to `true`. +/// +/// [^WF] Wasserman, S., & Faust, K. (1994). Social Network Analysis: +/// Methods and Applications (Structural Analysis in the Social Sciences). +/// Cambridge: Cambridge University Press. doi:10.1017/CBO9780511815478 +/// +/// Arguments: +/// +/// * `graph` - The graph object to run the algorithm on +/// * `wf_improved` - If `true`, scale by the fraction of nodes reachable. +/// +/// # Example +/// ```rust +/// use rustworkx_core::petgraph; +/// use rustworkx_core::centrality::closeness_centrality; +/// +/// // Calculate the closeness centrality of Graph +/// let g = petgraph::graph::UnGraph::::from_edges(&[ +/// (0, 4), (1, 2), (2, 3), (3, 4), (1, 4) +/// ]); +/// let output = closeness_centrality(&g, true); +/// assert_eq!( +/// vec![Some(1./2.), Some(2./3.), Some(4./7.), Some(2./3.), Some(4./5.)], +/// output +/// ); +/// +/// // Calculate the closeness centrality of DiGraph +/// let dg = petgraph::graph::DiGraph::::from_edges(&[ +/// (0, 4), (1, 2), (2, 3), (3, 4), (1, 4) +/// ]); +/// let output = closeness_centrality(&dg, true); +/// assert_eq!( +/// vec![Some(0.), Some(0.), Some(1./4.), Some(1./3.), Some(4./5.)], +/// output +/// ); +/// ``` +pub fn closeness_centrality(graph: G, wf_improved: bool) -> Vec> +where + G: NodeIndexable + + IntoNodeIdentifiers + + GraphBase + + IntoEdges + + Visitable + + NodeCount + + IntoEdgesDirected, + G::NodeId: std::hash::Hash + Eq, +{ + let max_index = graph.node_bound(); + let mut closeness: Vec> = vec![None; max_index]; + for node_s in graph.node_identifiers() { + let is = graph.to_index(node_s); + let map = dijkstra(Reversed(&graph), node_s, None, |_| 1); + let reachable_nodes_count = map.len(); + let dists_sum: usize = map.into_values().sum(); + if reachable_nodes_count == 1 { + closeness[is] = Some(0.0); + continue; + } + closeness[is] = Some((reachable_nodes_count - 1) as f64 / dists_sum as f64); + if wf_improved { + let node_count = graph.node_count(); + closeness[is] = closeness[is] + .map(|c| c * (reachable_nodes_count - 1) as f64 / (node_count - 1) as f64); + } + } + closeness +} diff --git a/rustworkx-core/tests/centrality.rs b/rustworkx-core/tests/centrality.rs new file mode 100644 index 000000000..66fa8dc06 --- /dev/null +++ b/rustworkx-core/tests/centrality.rs @@ -0,0 +1,100 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +use petgraph::visit::Reversed; +use rustworkx_core::centrality::closeness_centrality; +use rustworkx_core::petgraph::graph::{DiGraph, UnGraph}; + +#[test] +fn test_simple() { + let g = UnGraph::::from_edges(&[(1, 2), (2, 3), (3, 4), (1, 4)]); + let c = closeness_centrality(&g, true); + assert_eq!( + vec![ + Some(0.0), + Some(0.5625), + Some(0.5625), + Some(0.5625), + Some(0.5625) + ], + c + ); +} + +#[test] +fn test_wf_improved() { + let g = UnGraph::::from_edges(&[(0, 1), (1, 2), (2, 3), (4, 5), (5, 6)]); + let c = closeness_centrality(&g, true); + assert_eq!( + vec![ + Some(1.0 / 4.0), + Some(3.0 / 8.0), + Some(3.0 / 8.0), + Some(1.0 / 4.0), + Some(2.0 / 9.0), + Some(1.0 / 3.0), + Some(2.0 / 9.0) + ], + c + ); + let cwf = closeness_centrality(&g, false); + assert_eq!( + vec![ + Some(1.0 / 2.0), + Some(3.0 / 4.0), + Some(3.0 / 4.0), + Some(1.0 / 2.0), + Some(2.0 / 3.0), + Some(1.0), + Some(2.0 / 3.0) + ], + cwf + ); +} + +#[test] +fn test_digraph() { + let g = DiGraph::::from_edges(&[(0, 1), (1, 2)]); + let c = closeness_centrality(&g, true); + assert_eq!(vec![Some(0.), Some(1. / 2.), Some(2. / 3.)], c); + + let cr = closeness_centrality(Reversed(&g), true); + assert_eq!(vec![Some(2. / 3.), Some(1. / 2.), Some(0.)], cr); +} + +#[test] +fn test_k5() { + let g = UnGraph::::from_edges(&[ + (0, 1), + (0, 2), + (0, 3), + (0, 4), + (1, 2), + (1, 3), + (1, 4), + (2, 3), + (2, 4), + (3, 4), + ]); + let c = closeness_centrality(&g, true); + assert_eq!( + vec![Some(1.0), Some(1.0), Some(1.0), Some(1.0), Some(1.0)], + c + ); +} + +#[test] +fn test_path() { + let g = UnGraph::::from_edges(&[(0, 1), (1, 2)]); + let c = closeness_centrality(&g, true); + assert_eq!(vec![Some(2.0 / 3.0), Some(1.0), Some(2.0 / 3.0)], c); +} diff --git a/rustworkx/__init__.py b/rustworkx/__init__.py index c6104ce4e..40e3cd6c7 100644 --- a/rustworkx/__init__.py +++ b/rustworkx/__init__.py @@ -1600,9 +1600,65 @@ def _graph_betweenness_centrality(graph, normalized=True, endpoints=False, paral ) +@functools.singledispatch +def closeness_centrality(graph, wf_improved=True): + r"""Compute the closeness centrality of each node in a graph object. + + The closeness centrality of a node :math:`u` is defined as the + reciprocal of the average shortest path distance to :math:`u` over all + :math:`n-1` reachable nodes in the graph. In it's general form this can + be expressed as: + + .. math:: + + C(u) = \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, + + where: + + * :math:`d(v, u)` - the shortest-path distance between :math:`v` and + :math:`u` + * :math:`n` - the number of nodes that can reach :math:`u`. + + In the case of a graphs with more than one connected component there is + an alternative improved formula that calculates the closeness centrality + as "a ratio of the fraction of actors in the group who are reachable, to + the average distance" [WF]_. This can be expressed as + + .. math:: + + C_{WF}(u) = \frac{n-1}{N-1} \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, + + where :math:`N` is the number of nodes in the graph. This alternative + formula can be used with the ``wf_improved`` argument. + + :param graph: The input graph. Can either be a + :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph`. + :param bool wf_improved: This is optional; the default is True. If True, + scale by the fraction of nodes reachable. + + :returns: A dictionary mapping each node index to its closeness centrality. + :rtype: dict + + .. [WF] Wasserman, S., & Faust, K. (1994). Social Network Analysis: + Methods and Applications (Structural Analysis in the Social Sciences). + Cambridge: Cambridge University Press. doi:10.1017/CBO9780511815478 + """ + raise TypeError("Invalid input type %s for graph" % type(graph)) + + +@closeness_centrality.register(PyDiGraph) +def _digraph_closeness_centrality(graph, wf_improved=True): + return digraph_closeness_centrality(graph, wf_improved=wf_improved) + + +@closeness_centrality.register(PyGraph) +def _graph_closeness_centrality(graph, wf_improved=True): + return graph_closeness_centrality(graph, wf_improved=wf_improved) + + @functools.singledispatch def edge_betweenness_centrality(graph, normalized=True, parallel_threshold=50): - """Compute the edge betweenness centrality of all edges in a graph. + r"""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` @@ -1641,6 +1697,7 @@ def edge_betweenness_centrality(graph, normalized=True, parallel_threshold=50): betweenness score for each node. :rtype: EdgeCentralityMapping """ + raise TypeError("Invalid input type %s for graph" % type(graph)) @edge_betweenness_centrality.register(PyDiGraph) diff --git a/src/centrality.rs b/src/centrality.rs index 245494438..553fe9f97 100644 --- a/src/centrality.rs +++ b/src/centrality.rs @@ -160,6 +160,105 @@ pub fn digraph_betweenness_centrality( } } +/// Compute the closeness centrality of each node in a :class:`~.PyGraph` object. +/// +/// The closeness centrality of a node :math:`u` is defined as the +/// reciprocal of the average shortest path distance to :math:`u` over all +/// :math:`n-1` reachable nodes in the graph. In it's general form this can +/// be expressed as: +/// +/// .. math:: +/// +/// C(u) = \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, +/// +/// where: +/// +/// * :math:`d(v, u)` - the shortest-path distance between :math:`v` and +/// :math:`u` +/// * :math:`n` - the number of nodes that can reach :math:`u`. +/// +/// In the case of a graphs with more than one connected component there is +/// an alternative improved formula that calculates the closeness centrality +/// as "a ratio of the fraction of actors in the group who are reachable, to +/// the average distance" [WF]_. This can be expressed as +/// +/// .. math:: +/// +/// C_{WF}(u) = \frac{n-1}{N-1} \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, +/// +/// where :math:`N` is the number of nodes in the graph. This alternative +/// formula can be used with the ``wf_improved`` argument. +/// +/// :param PyGraph graph: The input graph. Can either be a +/// :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph`. +/// :param bool wf_improved: This is optional; the default is True. If True, +/// scale by the fraction of nodes reachable. +/// +/// :returns: A dictionary mapping each node index to its closeness centrality. +/// :rtype: CentralityMapping +#[pyfunction(signature = (graph, wf_improved=true))] +pub fn graph_closeness_centrality(graph: &graph::PyGraph, wf_improved: bool) -> CentralityMapping { + let closeness = centrality::closeness_centrality(&graph.graph, wf_improved); + CentralityMapping { + centralities: closeness + .into_iter() + .enumerate() + .filter_map(|(i, v)| v.map(|x| (i, x))) + .collect(), + } +} + +/// Compute the closeness centrality of each node in a :class:`~.PyDiGraph` object. +/// +/// The closeness centrality of a node :math:`u` is defined as the +/// reciprocal of the average shortest path distance to :math:`u` over all +/// :math:`n-1` reachable nodes in the graph. In it's general form this can +/// be expressed as: +/// +/// .. math:: +/// +/// C(u) = \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, +/// +/// where: +/// +/// * :math:`d(v, u)` - the shortest-path distance between :math:`v` and +/// :math:`u` +/// * :math:`n` - the number of nodes that can reach :math:`u`. +/// +/// In the case of a graphs with more than one connected component there is +/// an alternative improved formula that calculates the closeness centrality +/// as "a ratio of the fraction of actors in the group who are reachable, to +/// the average distance" [WF]_. This can be expressed as +/// +/// .. math:: +/// +/// C_{WF}(u) = \frac{n-1}{N-1} \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, +/// +/// where :math:`N` is the number of nodes in the graph. This alternative +/// formula can be used with the ``wf_improved`` argument. +/// +/// :param PyDiGraph graph: The input graph. Can either be a +/// :class:`~retworkx.PyGraph` or :class:`~retworkx.PyDiGraph`. +/// :param bool wf_improved: This is optional; the default is True. If True, +/// scale by the fraction of nodes reachable. +/// +/// :returns: A dictionary mapping each node index to its closeness centrality. +/// :rtype: CentralityMapping +#[pyfunction(signature = (graph, wf_improved=true))] +pub fn digraph_closeness_centrality( + graph: &digraph::PyDiGraph, + wf_improved: bool, +) -> CentralityMapping { + let closeness = centrality::closeness_centrality(&graph.graph, wf_improved); + CentralityMapping { + centralities: closeness + .into_iter() + .enumerate() + .filter_map(|(i, v)| v.map(|x| (i, x))) + .collect(), + } +} + /// 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 diff --git a/src/lib.rs b/src/lib.rs index c086afec7..219e2f952 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_closeness_centrality))?; + m.add_wrapped(wrap_pyfunction!(digraph_closeness_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))?; diff --git a/tests/retworkx_backwards_compat/digraph/test_centrality.py b/tests/retworkx_backwards_compat/digraph/test_centrality.py index 674571273..eadb47614 100644 --- a/tests/retworkx_backwards_compat/digraph/test_centrality.py +++ b/tests/retworkx_backwards_compat/digraph/test_centrality.py @@ -86,6 +86,16 @@ def test_betweenness_centrality_unnormalized_parallel(self): expected = {0: 0.0, 1: 2.0, 2: 2.0, 3: 0.0} self.assertEqual(expected, betweenness) + def test_closeness_centrality(self): + closeness = retworkx.digraph_closeness_centrality(self.graph) + expected = {0: 0.0, 1: 1.0 / 3.0, 2: 4.0 / 9.0, 3: 0.5} + self.assertEqual(expected, closeness) + + def test_closeness_centrality_wf_improved(self): + closeness = retworkx.digraph_closeness_centrality(self.graph, wf_improved=False) + expected = {0: 0.0, 1: 1.0, 2: 2.0 / 3.0, 3: 0.5} + self.assertEqual(expected, closeness) + class TestCentralityDiGraphDeletedNode(unittest.TestCase): def setUp(self): diff --git a/tests/retworkx_backwards_compat/graph/test_centrality.py b/tests/retworkx_backwards_compat/graph/test_centrality.py index d7a51e9dd..2c0dabee8 100644 --- a/tests/retworkx_backwards_compat/graph/test_centrality.py +++ b/tests/retworkx_backwards_compat/graph/test_centrality.py @@ -57,6 +57,16 @@ def test_betweenness_centrality_unnormalized(self): expected = {0: 0.0, 1: 2.0, 2: 2.0, 3: 0.0} self.assertEqual(expected, betweenness) + def test_closeness_centrality(self): + closeness = retworkx.graph_closeness_centrality(self.graph) + expected = {0: 0.5, 1: 0.75, 2: 0.75, 3: 0.5} + self.assertEqual(expected, closeness) + + def test_closeness_centrality_wf_improved(self): + closeness = retworkx.graph_closeness_centrality(self.graph, wf_improved=False) + expected = {0: 0.5, 1: 0.75, 2: 0.75, 3: 0.5} + self.assertEqual(expected, closeness) + class TestCentralityGraphDeletedNode(unittest.TestCase): def setUp(self): diff --git a/tests/retworkx_backwards_compat/test_dispatch.py b/tests/retworkx_backwards_compat/test_dispatch.py index 575bdda35..5bdc83e55 100644 --- a/tests/retworkx_backwards_compat/test_dispatch.py +++ b/tests/retworkx_backwards_compat/test_dispatch.py @@ -101,6 +101,10 @@ def test_betweenness_centrality(self): res = retworkx.betweenness_centrality(self.graph) self.assertIsInstance(res, retworkx.CentralityMapping) + def test_closeness_centrality(self): + res = retworkx.closeness_centrality(self.graph) + self.assertIsInstance(res, retworkx.CentralityMapping) + class TestDispatchPyDiGraph(TestDispatchPyGraph): diff --git a/tests/rustworkx_tests/digraph/test_centrality.py b/tests/rustworkx_tests/digraph/test_centrality.py index 071890eba..b97a8527e 100644 --- a/tests/rustworkx_tests/digraph/test_centrality.py +++ b/tests/rustworkx_tests/digraph/test_centrality.py @@ -130,6 +130,16 @@ def test_betweenness_centrality_unnormalized(self): expected = {0: 0.0, 1: 2.0, 2: 2.0, 4: 0.0} self.assertEqual(expected, betweenness) + def test_closeness_centrality(self): + closeness = rustworkx.digraph_closeness_centrality(self.graph) + expected = {0: 0.0, 1: 1.0 / 3.0, 2: 4.0 / 9.0, 4: 0.5} + self.assertEqual(expected, closeness) + + def test_closeness_centrality_wf_improved(self): + closeness = rustworkx.digraph_closeness_centrality(self.graph, wf_improved=False) + expected = {0: 0.0, 1: 1.0, 2: 2.0 / 3.0, 4: 0.5} + self.assertEqual(expected, closeness) + class TestEigenvectorCentrality(unittest.TestCase): def test_complete_graph(self): diff --git a/tests/rustworkx_tests/graph/test_centrality.py b/tests/rustworkx_tests/graph/test_centrality.py index 340c84247..31e88ed7e 100644 --- a/tests/rustworkx_tests/graph/test_centrality.py +++ b/tests/rustworkx_tests/graph/test_centrality.py @@ -101,6 +101,16 @@ def test_betweenness_centrality_unnormalized(self): expected = {0: 0.0, 1: 2.0, 2: 2.0, 4: 0.0} self.assertEqual(expected, betweenness) + def test_closeness_centrality(self): + closeness = rustworkx.graph_closeness_centrality(self.graph) + expected = {0: 0.5, 1: 0.75, 2: 0.75, 4: 0.5} + self.assertEqual(expected, closeness) + + def test_closeness_centrality_wf_improved(self): + closeness = rustworkx.graph_closeness_centrality(self.graph, wf_improved=False) + expected = {0: 0.5, 1: 0.75, 2: 0.75, 4: 0.5} + self.assertEqual(expected, closeness) + class TestEigenvectorCentrality(unittest.TestCase): def test_complete_graph(self):