Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Kirchhoff index / Effective graph resistance #6926

Merged
merged 25 commits into from Dec 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/reference/algorithms/distance_measures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Distance Measures
center
diameter
eccentricity
effective_graph_resistance
kemeny_constant
periphery
radius
Expand Down
93 changes: 91 additions & 2 deletions networkx/algorithms/distance_measures.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"barycenter",
"resistance_distance",
"kemeny_constant",
"effective_graph_resistance",
]


Expand Down Expand Up @@ -648,7 +649,7 @@ def _count_lu_permutations(perm_array):
@not_implemented_for("directed")
@nx._dispatch(edge_attrs="weight")
def resistance_distance(G, nodeA=None, nodeB=None, weight=None, invert_weight=True):
"""Returns the resistance distance between every pair of nodes on graph G.
"""Returns the resistance distance between pairs of nodes in graph G.

The resistance distance between two nodes of a graph is akin to treating
the graph as a grid of resistors with a resistance equal to the provided
Expand Down Expand Up @@ -814,6 +815,94 @@ def resistance_distance(G, nodeA=None, nodeB=None, weight=None, invert_weight=Tr
return rd


@not_implemented_for("directed")
@nx._dispatch(edge_attrs="weight")
def effective_graph_resistance(G, weight=None, invert_weight=True):
"""Returns the Effective graph resistance of G.

Also known as the Kirchhoff index.

The effective graph resistance is defined as the sum
of the resistance distance of every node pair in G [1]_.

If weight is not provided, then a weight of 1 is used for all edges.

The effective graph resistance of a disconnected graph is infinite.

Parameters
----------
G : NetworkX graph
A graph

weight : string or None, optional (default=None)
The edge data key used to compute the effective graph resistance.
If None, then each edge has weight 1.

invert_weight : boolean (default=True)
Proper calculation of resistance distance requires building the
Laplacian matrix with the reciprocal of the weight. Not required
if the weight is already inverted. Weight cannot be zero.

Returns
-------
RG : float
dschult marked this conversation as resolved.
Show resolved Hide resolved
The effective graph resistance of `G`.

Raises
-------
NetworkXNotImplemented
If `G` is a directed graph.

NetworkXError
If `G` does not contain any nodes.

Examples
--------
>>> G = nx.Graph([(1, 2), (1, 3), (1, 4), (3, 4), (3, 5), (4, 5)])
>>> round(nx.effective_graph_resistance(G), 10)
10.25

Notes
-----
The implementation is based on Theorem 2.2 in [2]_. Self-loops are ignored.
Multi-edges are contracted in one edge with weight equal to the harmonic sum of the weights.

References
----------
.. [1] Wolfram
"Kirchhoff Index."
https://mathworld.wolfram.com/KirchhoffIndex.html
.. [2] W. Ellens, F. M. Spieksma, P. Van Mieghem, A. Jamakovic, R. E. Kooij.
Effective graph resistance.
Lin. Alg. Appl. 435:2491-2506, 2011.
"""
import numpy as np

if len(G) == 0:
raise nx.NetworkXError("Graph G must contain at least one node.")

# Disconnected graphs have infinite Effective graph resistance
if not nx.is_connected(G):
return np.inf

# Invert weights
G = G.copy()
if invert_weight and weight is not None:
if G.is_multigraph():
for u, v, k, d in G.edges(keys=True, data=True):
d[weight] = 1 / d[weight]
else:
for u, v, d in G.edges(data=True):
d[weight] = 1 / d[weight]

# Get Laplacian eigenvalues
mu = np.sort(nx.laplacian_spectrum(G, weight=weight))

# Compute Effective graph resistance based on spectrum of the Laplacian
# Self-loops are ignored
return np.sum(1 / mu[1:]) * G.number_of_nodes()


@nx.utils.not_implemented_for("directed")
@nx._dispatch(edge_attrs="weight")
def kemeny_constant(G, *, weight=None):
Expand Down Expand Up @@ -844,7 +933,7 @@ def kemeny_constant(G, *, weight=None):

Returns
-------
K : float
float
The Kemeny constant of the graph `G`.

Raises
Expand Down
85 changes: 85 additions & 0 deletions networkx/algorithms/tests/test_distance_measures.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,91 @@ def test_resistance_distance_all(self):
assert round(rd[1][3], 5) == 1


class TestEffectiveGraphResistance:
@classmethod
def setup_class(cls):
global np
np = pytest.importorskip("numpy")

def setup_method(self):
G = nx.Graph()
G.add_edge(1, 2, weight=2)
G.add_edge(1, 3, weight=1)
G.add_edge(2, 3, weight=4)
self.G = G

def test_effective_graph_resistance_directed_graph(self):
G = nx.DiGraph()
with pytest.raises(nx.NetworkXNotImplemented):
nx.effective_graph_resistance(G)

def test_effective_graph_resistance_empty(self):
G = nx.Graph()
with pytest.raises(nx.NetworkXError):
nx.effective_graph_resistance(G)

def test_effective_graph_resistance_not_connected(self):
G = nx.Graph([(1, 2), (3, 4)])
RG = nx.effective_graph_resistance(G)
assert np.isinf(RG)

def test_effective_graph_resistance(self):
RG = nx.effective_graph_resistance(self.G, "weight", True)
rd12 = 1 / (1 / (1 + 4) + 1 / 2)
rd13 = 1 / (1 / (1 + 2) + 1 / 4)
rd23 = 1 / (1 / (2 + 4) + 1 / 1)
assert np.isclose(RG, rd12 + rd13 + rd23)

def test_effective_graph_resistance_noinv(self):
RG = nx.effective_graph_resistance(self.G, "weight", False)
rd12 = 1 / (1 / (1 / 1 + 1 / 4) + 1 / (1 / 2))
rd13 = 1 / (1 / (1 / 1 + 1 / 2) + 1 / (1 / 4))
rd23 = 1 / (1 / (1 / 2 + 1 / 4) + 1 / (1 / 1))
assert np.isclose(RG, rd12 + rd13 + rd23)

def test_effective_graph_resistance_no_weight(self):
RG = nx.effective_graph_resistance(self.G)
assert np.isclose(RG, 2)

def test_effective_graph_resistance_neg_weight(self):
self.G[2][3]["weight"] = -4
RG = nx.effective_graph_resistance(self.G, "weight", True)
rd12 = 1 / (1 / (1 + -4) + 1 / 2)
rd13 = 1 / (1 / (1 + 2) + 1 / (-4))
rd23 = 1 / (1 / (2 + -4) + 1 / 1)
assert np.isclose(RG, rd12 + rd13 + rd23)

def test_effective_graph_resistance_multigraph(self):
G = nx.MultiGraph()
G.add_edge(1, 2, weight=2)
G.add_edge(1, 3, weight=1)
G.add_edge(2, 3, weight=1)
G.add_edge(2, 3, weight=3)
RG = nx.effective_graph_resistance(G, "weight", True)
edge23 = 1 / (1 / 1 + 1 / 3)
rd12 = 1 / (1 / (1 + edge23) + 1 / 2)
rd13 = 1 / (1 / (1 + 2) + 1 / edge23)
rd23 = 1 / (1 / (2 + edge23) + 1 / 1)
assert np.isclose(RG, rd12 + rd13 + rd23)

def test_effective_graph_resistance_div0(self):
with pytest.raises(ZeroDivisionError):
self.G[1][2]["weight"] = 0
nx.effective_graph_resistance(self.G, "weight")

def test_effective_graph_resistance_complete_graph(self):
N = 10
G = nx.complete_graph(N)
RG = nx.effective_graph_resistance(G)
assert np.isclose(RG, N - 1)

def test_effective_graph_resistance_path_graph(self):
N = 10
G = nx.path_graph(N)
RG = nx.effective_graph_resistance(G)
assert np.isclose(RG, (N - 1) * N * (N + 1) // 6)


class TestBarycenter:
"""Test :func:`networkx.algorithms.distance_measures.barycenter`."""

Expand Down