From 333bd1e54a411e093f787763430a5ca489b2949b Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 25 Apr 2022 00:09:40 -0500 Subject: [PATCH 1/9] Add `triangles` I think the tests could be improved. Some of the NetworkX tests get coverage, but only compare to 0 triangles. Also, we should test more with self-edges. There may be better ways to compute triangles for: - all nodes - a subset of nodes - a single node There are *a lot* of different ways to compute triangles, so this could be explored further in the future. I hope the current PR is competitive. --- graphblas_algorithms/__init__.py | 1 + graphblas_algorithms/cluster.py | 60 ++++++++++++++++++++++ graphblas_algorithms/tests/test_cluster.py | 19 +++++++ 3 files changed, 80 insertions(+) create mode 100644 graphblas_algorithms/cluster.py create mode 100644 graphblas_algorithms/tests/test_cluster.py diff --git a/graphblas_algorithms/__init__.py b/graphblas_algorithms/__init__.py index 3a60de1..307025a 100644 --- a/graphblas_algorithms/__init__.py +++ b/graphblas_algorithms/__init__.py @@ -1,4 +1,5 @@ from . import _version +from .cluster import triangles # noqa from .link_analysis import pagerank # noqa __version__ = _version.get_versions()["version"] diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py new file mode 100644 index 0000000..37acb07 --- /dev/null +++ b/graphblas_algorithms/cluster.py @@ -0,0 +1,60 @@ +from collections import OrderedDict + +import graphblas as gb +from graphblas import Matrix, Vector, binary, select +from graphblas.semiring import any_pair, plus_pair +from networkx.utils import not_implemented_for + + +def single_triangle_core(G, index): + M = Matrix(bool, G.nrows, G.ncols) + M[index, index] = False + C = any_pair(G.T @ M.T).new(name="C") # select.coleq(G, index) + del C[index, index] # Ignore self-edges + R = C.T.new(name="R") + return plus_pair(G @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value // 2 + + +def triangles_core(G, mask=None): + # Ignores self-edges + L = select.tril(G, -1).new(name="L") + U = select.triu(G, 1).new(name="U") + C = plus_pair(L @ L.T).new(mask=L.S) + return ( + C.reduce_rowwise().new(mask=mask) + + C.reduce_columnwise().new(mask=mask) + + plus_pair(U @ L.T).new(mask=U.S).reduce_rowwise().new(mask=mask) + ).new(name="triangles") + + +def total_triangles_core(G): + # Ignores self-edges + L = select.tril(G, -1).new(name="L") + U = select.triu(G, 1).new(name="U") + return plus_pair(L @ U.T).new(mask=L.S).reduce_scalar(allow_empty=False).value + + +@not_implemented_for("directed") +def triangles(G, nodes=None): + N = len(G) + if N == 0: + return {} + node_ids = OrderedDict((k, i) for i, k in enumerate(G)) + A = gb.io.from_networkx(G, nodelist=node_ids, weight=None, dtype=bool) + if nodes in G: + return single_triangle_core(A, node_ids[nodes]) + if nodes is not None: + id_to_key = {node_ids[key]: key for key in nodes} + mask = Vector.from_values(list(id_to_key), True, size=N, dtype=bool, name="mask").S + else: + mask = None + result = triangles_core(A, mask=mask) + if nodes is not None: + if result.nvals != len(id_to_key): + result(mask, binary.first) << 0 + indices, values = result.to_values() + return {id_to_key[index]: value for index, value in zip(indices, values)} + elif result.nvals != N: + # Fill with zero + result(mask=~result.S) << 0 + return dict(zip(node_ids, result.to_values()[1])) diff --git a/graphblas_algorithms/tests/test_cluster.py b/graphblas_algorithms/tests/test_cluster.py new file mode 100644 index 0000000..885a5da --- /dev/null +++ b/graphblas_algorithms/tests/test_cluster.py @@ -0,0 +1,19 @@ +import inspect + +import networkx as nx + +from graphblas_algorithms import triangles + +nx_triangles = nx.triangles +nx.triangles = triangles +nx.algorithms.triangles = triangles +nx.algorithms.cluster.triangles = triangles + + +def test_signatures(): + nx_sig = inspect.signature(nx_triangles) + sig = inspect.signature(triangles) + assert nx_sig == sig + + +from networkx.algorithms.tests.test_cluster import * # noqa isort:skip From 60011463df6059bd5a5131782baac9ca8a860ca2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 25 Apr 2022 13:02:47 -0500 Subject: [PATCH 2/9] Handle and test triangle count with self-edges --- graphblas_algorithms/cluster.py | 29 +++++++++++++++------- graphblas_algorithms/tests/test_cluster.py | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index 37acb07..d4f3564 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -6,19 +6,26 @@ from networkx.utils import not_implemented_for -def single_triangle_core(G, index): +def single_triangle_core(G, index, *, L=None): + if L is None: + # Pretty much all the time is spent here. + # We take the TRIL as a way to ignore the self-edges. + # If we knew there were no self-edges, we could use G below instead of L. + L = select.tril(G, -1).new(name="L") M = Matrix(bool, G.nrows, G.ncols) M[index, index] = False - C = any_pair(G.T @ M.T).new(name="C") # select.coleq(G, index) + C = any_pair(G.T @ M.T).new(name="C") # select.coleq(G.T, index) del C[index, index] # Ignore self-edges R = C.T.new(name="R") - return plus_pair(G @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value // 2 + return plus_pair(L @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value -def triangles_core(G, mask=None): +def triangles_core(G, mask=None, *, L=None, U=None): # Ignores self-edges - L = select.tril(G, -1).new(name="L") - U = select.triu(G, 1).new(name="U") + if L is None: + L = select.tril(G, -1).new(name="L") + if U is None: + U = select.triu(G, 1).new(name="U") C = plus_pair(L @ L.T).new(mask=L.S) return ( C.reduce_rowwise().new(mask=mask) @@ -27,10 +34,14 @@ def triangles_core(G, mask=None): ).new(name="triangles") -def total_triangles_core(G): +def total_triangles_core(G, *, L=None, U=None): # Ignores self-edges - L = select.tril(G, -1).new(name="L") - U = select.triu(G, 1).new(name="U") + # We use SandiaDot method, because it's usually the fastest on large graphs. + # For smaller graphs, Sandia method is usually faster: plus_pair(L @ L).new(mask(L.S)) + if L is None: + L = select.tril(G, -1).new(name="L") + if U is None: + U = select.triu(G, 1).new(name="U") return plus_pair(L @ U.T).new(mask=L.S).reduce_scalar(allow_empty=False).value diff --git a/graphblas_algorithms/tests/test_cluster.py b/graphblas_algorithms/tests/test_cluster.py index 885a5da..14b52e2 100644 --- a/graphblas_algorithms/tests/test_cluster.py +++ b/graphblas_algorithms/tests/test_cluster.py @@ -1,7 +1,9 @@ import inspect +import graphblas as gb import networkx as nx +import graphblas_algorithms as ga from graphblas_algorithms import triangles nx_triangles = nx.triangles @@ -16,4 +18,27 @@ def test_signatures(): assert nx_sig == sig +def test_triangles_full(): + # Including self-edges! + G = gb.Matrix(bool, 5, 5) + G[:, :] = True + L = gb.select.tril(G, -1).new(name="L") + U = gb.select.triu(G, 1).new(name="U") + result = ga.cluster.triangles_core(G, L=L, U=U) + expected = gb.Vector(int, 5) + expected[:] = 6 + assert result.isequal(expected) + mask = gb.Vector(bool, 5) + mask[0] = True + mask[3] = True + result = ga.cluster.triangles_core(G, mask=mask.S) + expected = gb.Vector(int, 5) + expected[0] = 6 + expected[3] = 6 + assert result.isequal(expected) + assert ga.cluster.single_triangle_core(G, 0, L=L) == 6 + assert ga.cluster.total_triangles_core(G) == 10 + assert ga.cluster.total_triangles_core(G, L=L, U=U) == 10 + + from networkx.algorithms.tests.test_cluster import * # noqa isort:skip From ee267d3423530bee943cc0d80327763110eae7eb Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 25 Apr 2022 14:28:46 -0500 Subject: [PATCH 3/9] Add `has_self_edges=True` argument for single triangle count --- graphblas_algorithms/cluster.py | 18 ++++++++++-------- graphblas_algorithms/tests/test_cluster.py | 8 ++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index d4f3564..f1189cb 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -6,18 +6,20 @@ from networkx.utils import not_implemented_for -def single_triangle_core(G, index, *, L=None): - if L is None: - # Pretty much all the time is spent here. - # We take the TRIL as a way to ignore the self-edges. - # If we knew there were no self-edges, we could use G below instead of L. - L = select.tril(G, -1).new(name="L") +def single_triangle_core(G, index, *, L=None, has_self_edges=True): M = Matrix(bool, G.nrows, G.ncols) M[index, index] = False C = any_pair(G.T @ M.T).new(name="C") # select.coleq(G.T, index) del C[index, index] # Ignore self-edges R = C.T.new(name="R") - return plus_pair(L @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value + if has_self_edges: + if L is None: + # Pretty much all the time is spent here. + # We take the TRIL as a way to ignore the self-edges. + L = select.tril(G, -1).new(name="L") + return plus_pair(L @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value + else: + return plus_pair(G @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value // 2 def triangles_core(G, mask=None, *, L=None, U=None): @@ -37,7 +39,7 @@ def triangles_core(G, mask=None, *, L=None, U=None): def total_triangles_core(G, *, L=None, U=None): # Ignores self-edges # We use SandiaDot method, because it's usually the fastest on large graphs. - # For smaller graphs, Sandia method is usually faster: plus_pair(L @ L).new(mask(L.S)) + # For smaller graphs, Sandia method is usually faster: plus_pair(L @ L).new(mask=L.S) if L is None: L = select.tril(G, -1).new(name="L") if U is None: diff --git a/graphblas_algorithms/tests/test_cluster.py b/graphblas_algorithms/tests/test_cluster.py index 14b52e2..35456c2 100644 --- a/graphblas_algorithms/tests/test_cluster.py +++ b/graphblas_algorithms/tests/test_cluster.py @@ -22,12 +22,15 @@ def test_triangles_full(): # Including self-edges! G = gb.Matrix(bool, 5, 5) G[:, :] = True + G2 = gb.select.offdiag(G).new() L = gb.select.tril(G, -1).new(name="L") U = gb.select.triu(G, 1).new(name="U") result = ga.cluster.triangles_core(G, L=L, U=U) expected = gb.Vector(int, 5) expected[:] = 6 assert result.isequal(expected) + result = ga.cluster.triangles_core(G2, L=L, U=U) + assert result.isequal(expected) mask = gb.Vector(bool, 5) mask[0] = True mask[3] = True @@ -36,7 +39,12 @@ def test_triangles_full(): expected[0] = 6 expected[3] = 6 assert result.isequal(expected) + result = ga.cluster.triangles_core(G2, mask=mask.S) + assert result.isequal(expected) + assert ga.cluster.single_triangle_core(G, 1) == 6 assert ga.cluster.single_triangle_core(G, 0, L=L) == 6 + assert ga.cluster.single_triangle_core(G2, 0, has_self_edges=False) == 6 + assert ga.cluster.total_triangles_core(G2) == 10 assert ga.cluster.total_triangles_core(G) == 10 assert ga.cluster.total_triangles_core(G, L=L, U=U) == 10 From b9b61da3166a0f37697fe97b8c04beef378a1354 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 25 Apr 2022 15:19:47 -0500 Subject: [PATCH 4/9] Tiny improvement --- README.md | 3 +-- graphblas_algorithms/cluster.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c58a7d2..aa64bbc 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ [![pypi](https://img.shields.io/pypi/v/graphblas-algorithms.svg)](https://pypi.python.org/pypi/graphblas-algorithms/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/python-graphblas/graphblas-algorithms/blob/main/LICENSE) [![Tests](https://github.com/python-graphblas/graphblas-algorithms/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/graphblas-algorithms/actions) -[![Coverage](https://coveralls.io/repos/python-graphblas/graphblas-algorithms/badge.svg?branch=main)](https://coveralls.io/r/python-graphblas/graphblas-algorithms) -[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index f1189cb..2ff6e4e 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -10,7 +10,8 @@ def single_triangle_core(G, index, *, L=None, has_self_edges=True): M = Matrix(bool, G.nrows, G.ncols) M[index, index] = False C = any_pair(G.T @ M.T).new(name="C") # select.coleq(G.T, index) - del C[index, index] # Ignore self-edges + if has_self_edges: + del C[index, index] # Ignore self-edges R = C.T.new(name="R") if has_self_edges: if L is None: From fc849a930977050f23c42bc44e11ad53cbe2570d Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 25 Apr 2022 20:29:14 -0500 Subject: [PATCH 5/9] Add transitivity --- graphblas_algorithms/__init__.py | 2 +- graphblas_algorithms/cluster.py | 26 +++++++++++++++++++++- graphblas_algorithms/tests/test_cluster.py | 12 +++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/graphblas_algorithms/__init__.py b/graphblas_algorithms/__init__.py index 307025a..be4acc4 100644 --- a/graphblas_algorithms/__init__.py +++ b/graphblas_algorithms/__init__.py @@ -1,5 +1,5 @@ from . import _version -from .cluster import triangles # noqa +from .cluster import transitivity, triangles # noqa from .link_analysis import pagerank # noqa __version__ = _version.get_versions()["version"] diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index 2ff6e4e..ce1af7c 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -1,7 +1,7 @@ from collections import OrderedDict import graphblas as gb -from graphblas import Matrix, Vector, binary, select +from graphblas import Matrix, Vector, agg, binary, select from graphblas.semiring import any_pair, plus_pair from networkx.utils import not_implemented_for @@ -72,3 +72,27 @@ def triangles(G, nodes=None): # Fill with zero result(mask=~result.S) << 0 return dict(zip(node_ids, result.to_values()[1])) + + +def transitivity_core(G, *, L=None, U=None, has_self_edges=True): + if L is None: + L = select.tril(G, -1).new(name="L") + if U is None: + U = select.triu(G, 1).new(name="U") + numerator = total_triangles_core(G, L=L, U=U) + if numerator == 0: + return 0 + if has_self_edges: + degrees = L.reduce_rowwise(agg.count) + U.reduce_rowwise(agg.count) + else: + degrees = G.reduce_rowwise(agg.count) + denom = (degrees * (degrees - 1)).reduce().value + return 6 * numerator / denom + + +@not_implemented_for("directed") +def transitivity(G): + if len(G) == 0: + return 0 + A = gb.io.from_networkx(G, weight=None, dtype=bool) + return transitivity_core(A) diff --git a/graphblas_algorithms/tests/test_cluster.py b/graphblas_algorithms/tests/test_cluster.py index 35456c2..e000e9b 100644 --- a/graphblas_algorithms/tests/test_cluster.py +++ b/graphblas_algorithms/tests/test_cluster.py @@ -4,18 +4,26 @@ import networkx as nx import graphblas_algorithms as ga -from graphblas_algorithms import triangles +from graphblas_algorithms import transitivity, triangles nx_triangles = nx.triangles nx.triangles = triangles nx.algorithms.triangles = triangles nx.algorithms.cluster.triangles = triangles +nx_transitivity = nx.transitivity +nx.transitivity = transitivity +nx.algorithms.transitivity = transitivity +nx.algorithms.cluster.transitivity = transitivity + def test_signatures(): nx_sig = inspect.signature(nx_triangles) sig = inspect.signature(triangles) assert nx_sig == sig + nx_sig = inspect.signature(nx_transitivity) + sig = inspect.signature(transitivity) + assert nx_sig == sig def test_triangles_full(): @@ -47,6 +55,8 @@ def test_triangles_full(): assert ga.cluster.total_triangles_core(G2) == 10 assert ga.cluster.total_triangles_core(G) == 10 assert ga.cluster.total_triangles_core(G, L=L, U=U) == 10 + assert ga.cluster.transitivity_core(G) == 1.0 + assert ga.cluster.transitivity_core(G2, has_self_edges=False) == 1.0 from networkx.algorithms.tests.test_cluster import * # noqa isort:skip From d65ca8524129883e16c1e877074c23db053a8179 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 26 Apr 2022 00:24:46 -0500 Subject: [PATCH 6/9] Begin clustering coefficient; also, make computing properties easier. --- graphblas_algorithms/cluster.py | 77 +++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index ce1af7c..e73fe01 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -6,6 +6,38 @@ from networkx.utils import not_implemented_for +def get_properties(G, names, *, L=None, U=None, degrees=None, has_self_edges=True): + if isinstance(names, str): + # Separated by commas and/or spaces + names = [name for name in names.replace(" ", ",").split(",") if name] + rv = [] + for name in names: + if name == "L": + if L is None: + L = select.tril(G, -1).new(name="L") + rv.append(L) + elif name == "U": + if U is None: + U = select.triu(G, 1).new(name="U") + rv.append(U) + elif name == "degrees": + if degrees is None: + if has_self_edges: + if L is None or U is None: + L, U, degrees = get_properties(G, "L U degrees", L=L, U=U, degrees=degrees) + degrees = (L.reduce_rowwise(agg.count) + U.reduce_rowwise(agg.count)).new( + name="degrees" + ) + else: + degrees = G.reduce_rowwise(agg.count).new(name="degrees") + rv.append(degrees) + else: + raise ValueError(f"Unknown property name: {name}") + if len(rv) == 1: + return rv[0] + return rv + + def single_triangle_core(G, index, *, L=None, has_self_edges=True): M = Matrix(bool, G.nrows, G.ncols) M[index, index] = False @@ -14,10 +46,9 @@ def single_triangle_core(G, index, *, L=None, has_self_edges=True): del C[index, index] # Ignore self-edges R = C.T.new(name="R") if has_self_edges: - if L is None: - # Pretty much all the time is spent here. - # We take the TRIL as a way to ignore the self-edges. - L = select.tril(G, -1).new(name="L") + # Pretty much all the time is spent here taking TRIL. + # We take the TRIL as a way to ignore the self-edges. + L = get_properties(G, "L", L=L) return plus_pair(L @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value else: return plus_pair(G @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value // 2 @@ -25,10 +56,7 @@ def single_triangle_core(G, index, *, L=None, has_self_edges=True): def triangles_core(G, mask=None, *, L=None, U=None): # Ignores self-edges - if L is None: - L = select.tril(G, -1).new(name="L") - if U is None: - U = select.triu(G, 1).new(name="U") + L, U = get_properties(G, "L U", L=L, U=U) C = plus_pair(L @ L.T).new(mask=L.S) return ( C.reduce_rowwise().new(mask=mask) @@ -41,10 +69,7 @@ def total_triangles_core(G, *, L=None, U=None): # Ignores self-edges # We use SandiaDot method, because it's usually the fastest on large graphs. # For smaller graphs, Sandia method is usually faster: plus_pair(L @ L).new(mask=L.S) - if L is None: - L = select.tril(G, -1).new(name="L") - if U is None: - U = select.triu(G, 1).new(name="U") + L, U = get_properties(G, "L U", L=L, U=U) return plus_pair(L @ U.T).new(mask=L.S).reduce_scalar(allow_empty=False).value @@ -74,25 +99,33 @@ def triangles(G, nodes=None): return dict(zip(node_ids, result.to_values()[1])) -def transitivity_core(G, *, L=None, U=None, has_self_edges=True): - if L is None: - L = select.tril(G, -1).new(name="L") - if U is None: - U = select.triu(G, 1).new(name="U") +def transitivity_core(G, *, L=None, U=None, degrees=None, has_self_edges=True): + L, U = get_properties(G, "L U", L=L, U=U) numerator = total_triangles_core(G, L=L, U=U) if numerator == 0: return 0 - if has_self_edges: - degrees = L.reduce_rowwise(agg.count) + U.reduce_rowwise(agg.count) - else: - degrees = G.reduce_rowwise(agg.count) + degrees = get_properties(G, "degrees", L=L, U=U, degrees=degrees, has_self_edges=has_self_edges) denom = (degrees * (degrees - 1)).reduce().value return 6 * numerator / denom -@not_implemented_for("directed") +@not_implemented_for("directed") # Should we implement it for directed? def transitivity(G): if len(G) == 0: return 0 A = gb.io.from_networkx(G, weight=None, dtype=bool) return transitivity_core(A) + + +def clustering_core(G, *, L=None, U=None, degrees=None, has_self_edges=True): + L, U, degrees = get_properties( + G, "L U degrees", L=L, U=U, degrees=degrees, has_self_edges=has_self_edges + ) + tri = triangles_core(G, L=L, U=U) + denom = degrees * (degrees - 1) + return (2 * tri / denom).new(name="clustering") + + +@not_implemented_for("directed") # TODO: implement for directed +def clustering(G, nodes=None, weight=None): + pass From 2394edfa22535f569666960a9f08114ccaf53987 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 26 Apr 2022 11:46:49 -0500 Subject: [PATCH 7/9] Better handling of properties --- graphblas_algorithms/cluster.py | 44 +++++++++++++--------- graphblas_algorithms/tests/test_cluster.py | 2 +- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index e73fe01..c65b1d8 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -7,6 +7,7 @@ def get_properties(G, names, *, L=None, U=None, degrees=None, has_self_edges=True): + """Calculate properties of undirected graph""" if isinstance(names, str): # Separated by commas and/or spaces names = [name for name in names.replace(" ", ",").split(",") if name] @@ -22,15 +23,26 @@ def get_properties(G, names, *, L=None, U=None, degrees=None, has_self_edges=Tru rv.append(U) elif name == "degrees": if degrees is None: + if L is not None: + has_self_edges = G.nvals > 2 * L.nvals + elif U is not None: + has_self_edges = G.nvals > 2 * U.nvals if has_self_edges: if L is None or U is None: - L, U, degrees = get_properties(G, "L U degrees", L=L, U=U, degrees=degrees) + L, U = get_properties(G, "L U", L=L, U=U) degrees = (L.reduce_rowwise(agg.count) + U.reduce_rowwise(agg.count)).new( name="degrees" ) else: degrees = G.reduce_rowwise(agg.count).new(name="degrees") rv.append(degrees) + elif name == "has_self_edges": + # Compute if cheap + if L is not None: + has_self_edges = G.nvals > 2 * L.nvals + elif U is not None: + has_self_edges = G.nvals > 2 * U.nvals + rv.append(has_self_edges) else: raise ValueError(f"Unknown property name: {name}") if len(rv) == 1: @@ -45,9 +57,9 @@ def single_triangle_core(G, index, *, L=None, has_self_edges=True): if has_self_edges: del C[index, index] # Ignore self-edges R = C.T.new(name="R") + has_self_edges = get_properties(G, "has_self_edges", L=L, has_self_edges=has_self_edges) if has_self_edges: - # Pretty much all the time is spent here taking TRIL. - # We take the TRIL as a way to ignore the self-edges. + # Pretty much all the time is spent here taking TRIL, which is used to ignore self-edges L = get_properties(G, "L", L=L) return plus_pair(L @ R.T).new(mask=C.S).reduce_scalar(allow_empty=False).value else: @@ -65,14 +77,6 @@ def triangles_core(G, mask=None, *, L=None, U=None): ).new(name="triangles") -def total_triangles_core(G, *, L=None, U=None): - # Ignores self-edges - # We use SandiaDot method, because it's usually the fastest on large graphs. - # For smaller graphs, Sandia method is usually faster: plus_pair(L @ L).new(mask=L.S) - L, U = get_properties(G, "L U", L=L, U=U) - return plus_pair(L @ U.T).new(mask=L.S).reduce_scalar(allow_empty=False).value - - @not_implemented_for("directed") def triangles(G, nodes=None): N = len(G) @@ -99,12 +103,20 @@ def triangles(G, nodes=None): return dict(zip(node_ids, result.to_values()[1])) -def transitivity_core(G, *, L=None, U=None, degrees=None, has_self_edges=True): +def total_triangles_core(G, *, L=None, U=None): + # Ignores self-edges + # We use SandiaDot method, because it's usually the fastest on large graphs. + # For smaller graphs, Sandia method is usually faster: plus_pair(L @ L).new(mask=L.S) + L, U = get_properties(G, "L U", L=L, U=U) + return plus_pair(L @ U.T).new(mask=L.S).reduce_scalar(allow_empty=False).value + + +def transitivity_core(G, *, L=None, U=None, degrees=None): L, U = get_properties(G, "L U", L=L, U=U) numerator = total_triangles_core(G, L=L, U=U) if numerator == 0: return 0 - degrees = get_properties(G, "degrees", L=L, U=U, degrees=degrees, has_self_edges=has_self_edges) + degrees = get_properties(G, "degrees", L=L, U=U, degrees=degrees) denom = (degrees * (degrees - 1)).reduce().value return 6 * numerator / denom @@ -117,10 +129,8 @@ def transitivity(G): return transitivity_core(A) -def clustering_core(G, *, L=None, U=None, degrees=None, has_self_edges=True): - L, U, degrees = get_properties( - G, "L U degrees", L=L, U=U, degrees=degrees, has_self_edges=has_self_edges - ) +def clustering_core(G, *, L=None, U=None, degrees=None): + L, U, degrees = get_properties(G, "L U degrees", L=L, U=U, degrees=degrees) tri = triangles_core(G, L=L, U=U) denom = degrees * (degrees - 1) return (2 * tri / denom).new(name="clustering") diff --git a/graphblas_algorithms/tests/test_cluster.py b/graphblas_algorithms/tests/test_cluster.py index e000e9b..b6387ab 100644 --- a/graphblas_algorithms/tests/test_cluster.py +++ b/graphblas_algorithms/tests/test_cluster.py @@ -56,7 +56,7 @@ def test_triangles_full(): assert ga.cluster.total_triangles_core(G) == 10 assert ga.cluster.total_triangles_core(G, L=L, U=U) == 10 assert ga.cluster.transitivity_core(G) == 1.0 - assert ga.cluster.transitivity_core(G2, has_self_edges=False) == 1.0 + assert ga.cluster.transitivity_core(G2) == 1.0 from networkx.algorithms.tests.test_cluster import * # noqa isort:skip From e747ff1dd023d02c0fc17ebc467e08fba191f3d6 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 26 Apr 2022 12:47:34 -0500 Subject: [PATCH 8/9] Implement clustering for undirected, unweighted graphs --- graphblas_algorithms/__init__.py | 2 +- graphblas_algorithms/cluster.py | 83 +++++++++++++++++----- graphblas_algorithms/tests/test_cluster.py | 23 +++++- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/graphblas_algorithms/__init__.py b/graphblas_algorithms/__init__.py index be4acc4..a4d9f3b 100644 --- a/graphblas_algorithms/__init__.py +++ b/graphblas_algorithms/__init__.py @@ -1,5 +1,5 @@ from . import _version -from .cluster import transitivity, triangles # noqa +from .cluster import clustering, transitivity, triangles # noqa from .link_analysis import pagerank # noqa __version__ = _version.get_versions()["version"] diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index c65b1d8..9fbc02a 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -1,8 +1,10 @@ from collections import OrderedDict import graphblas as gb +import networkx as nx from graphblas import Matrix, Vector, agg, binary, select from graphblas.semiring import any_pair, plus_pair +from networkx import clustering as _nx_clustering from networkx.utils import not_implemented_for @@ -23,18 +25,7 @@ def get_properties(G, names, *, L=None, U=None, degrees=None, has_self_edges=Tru rv.append(U) elif name == "degrees": if degrees is None: - if L is not None: - has_self_edges = G.nvals > 2 * L.nvals - elif U is not None: - has_self_edges = G.nvals > 2 * U.nvals - if has_self_edges: - if L is None or U is None: - L, U = get_properties(G, "L U", L=L, U=U) - degrees = (L.reduce_rowwise(agg.count) + U.reduce_rowwise(agg.count)).new( - name="degrees" - ) - else: - degrees = G.reduce_rowwise(agg.count).new(name="degrees") + degrees = get_degrees(G, L=L, U=U, has_self_edges=has_self_edges) rv.append(degrees) elif name == "has_self_edges": # Compute if cheap @@ -50,14 +41,30 @@ def get_properties(G, names, *, L=None, U=None, degrees=None, has_self_edges=Tru return rv +def get_degrees(G, mask=None, *, L=None, U=None, has_self_edges=True): + if L is not None: + has_self_edges = G.nvals > 2 * L.nvals + elif U is not None: + has_self_edges = G.nvals > 2 * U.nvals + if has_self_edges: + if L is None or U is None: + L, U = get_properties(G, "L U", L=L, U=U) + degrees = ( + L.reduce_rowwise(agg.count).new(mask=mask) + U.reduce_rowwise(agg.count).new(mask=mask) + ).new(name="degrees") + else: + degrees = G.reduce_rowwise(agg.count).new(mask=mask, name="degrees") + return degrees + + def single_triangle_core(G, index, *, L=None, has_self_edges=True): M = Matrix(bool, G.nrows, G.ncols) M[index, index] = False C = any_pair(G.T @ M.T).new(name="C") # select.coleq(G.T, index) + has_self_edges = get_properties(G, "has_self_edges", L=L, has_self_edges=has_self_edges) if has_self_edges: del C[index, index] # Ignore self-edges R = C.T.new(name="R") - has_self_edges = get_properties(G, "has_self_edges", L=L, has_self_edges=has_self_edges) if has_self_edges: # Pretty much all the time is spent here taking TRIL, which is used to ignore self-edges L = get_properties(G, "L", L=L) @@ -129,13 +136,53 @@ def transitivity(G): return transitivity_core(A) -def clustering_core(G, *, L=None, U=None, degrees=None): - L, U, degrees = get_properties(G, "L U degrees", L=L, U=U, degrees=degrees) - tri = triangles_core(G, L=L, U=U) +def clustering_core(G, mask=None, *, L=None, U=None, degrees=None): + L, U = get_properties(G, "L U", L=L, U=U) + tri = triangles_core(G, mask=mask, L=L, U=U) + degrees = get_degrees(G, mask=mask, L=L, U=U) denom = degrees * (degrees - 1) return (2 * tri / denom).new(name="clustering") -@not_implemented_for("directed") # TODO: implement for directed +def single_clustering_core(G, index, *, L=None, degrees=None, has_self_edges=True): + has_self_edges = get_properties(G, "has_self_edges", L=L, has_self_edges=has_self_edges) + tri = single_triangle_core(G, index, L=L, has_self_edges=has_self_edges) + if tri == 0: + return 0 + if degrees is not None: + degrees = degrees[index].value + else: + row = G[index, :].new() + degrees = row.reduce(agg.count).value + if has_self_edges and row[index].value is not None: + degrees -= 1 + denom = degrees * (degrees - 1) + return 2 * tri / denom + + def clustering(G, nodes=None, weight=None): - pass + N = len(G) + if N == 0: + return {} + if isinstance(G, nx.DiGraph) or weight is not None: + # TODO: Not yet implemented. Clustering implemented only for undirected and unweighted. + return _nx_clustering(G, nodes=nodes, weight=weight) + node_ids = OrderedDict((k, i) for i, k in enumerate(G)) + A = gb.io.from_networkx(G, nodelist=node_ids, weight=weight) + if nodes in G: + return single_clustering_core(A, node_ids[nodes]) + if nodes is not None: + id_to_key = {node_ids[key]: key for key in nodes} + mask = Vector.from_values(list(id_to_key), True, size=N, dtype=bool, name="mask").S + else: + mask = None + result = clustering_core(A, mask=mask) + if nodes is not None: + if result.nvals != len(id_to_key): + result(mask, binary.first) << 0.0 + indices, values = result.to_values() + return {id_to_key[index]: value for index, value in zip(indices, values)} + elif result.nvals != N: + # Fill with zero + result(mask=~result.S) << 0.0 + return dict(zip(node_ids, result.to_values()[1])) diff --git a/graphblas_algorithms/tests/test_cluster.py b/graphblas_algorithms/tests/test_cluster.py index b6387ab..e5186ad 100644 --- a/graphblas_algorithms/tests/test_cluster.py +++ b/graphblas_algorithms/tests/test_cluster.py @@ -4,7 +4,7 @@ import networkx as nx import graphblas_algorithms as ga -from graphblas_algorithms import transitivity, triangles +from graphblas_algorithms import clustering, transitivity, triangles nx_triangles = nx.triangles nx.triangles = triangles @@ -16,6 +16,11 @@ nx.algorithms.transitivity = transitivity nx.algorithms.cluster.transitivity = transitivity +nx_clustering = nx.clustering +nx.clustering = clustering +nx.algorithms.clustering = clustering +nx.algorithms.cluster.clustering = clustering + def test_signatures(): nx_sig = inspect.signature(nx_triangles) @@ -24,6 +29,9 @@ def test_signatures(): nx_sig = inspect.signature(nx_transitivity) sig = inspect.signature(transitivity) assert nx_sig == sig + nx_sig = inspect.signature(nx_clustering) + sig = inspect.signature(clustering) + assert nx_sig == sig def test_triangles_full(): @@ -57,6 +65,19 @@ def test_triangles_full(): assert ga.cluster.total_triangles_core(G, L=L, U=U) == 10 assert ga.cluster.transitivity_core(G) == 1.0 assert ga.cluster.transitivity_core(G2) == 1.0 + result = ga.cluster.clustering_core(G) + expected = gb.Vector(float, 5) + expected[:] = 1 + assert result.isequal(expected) + result = ga.cluster.clustering_core(G2) + assert result.isequal(expected) + assert ga.cluster.single_clustering_core(G, 0) == 1 + assert ga.cluster.single_clustering_core(G2, 0) == 1 + expected(mask.S, replace=True) << 1 + result = ga.cluster.clustering_core(G, mask=mask.S) + assert result.isequal(expected) + result = ga.cluster.clustering_core(G2, mask=mask.S) + assert result.isequal(expected) from networkx.algorithms.tests.test_cluster import * # noqa isort:skip From 8ef5991b5f6c732ee6e0f428d639c57cf37368f3 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Tue, 26 Apr 2022 21:40:23 -0500 Subject: [PATCH 9/9] Add `average_clustering` and helper functions to make things cleaner --- graphblas_algorithms/__init__.py | 2 +- graphblas_algorithms/_utils.py | 46 +++++++++++++ graphblas_algorithms/cluster.py | 78 ++++++++++------------ graphblas_algorithms/link_analysis.py | 40 ++++------- graphblas_algorithms/tests/test_cluster.py | 11 ++- requirements.txt | 3 +- setup.py | 4 +- 7 files changed, 110 insertions(+), 74 deletions(-) create mode 100644 graphblas_algorithms/_utils.py diff --git a/graphblas_algorithms/__init__.py b/graphblas_algorithms/__init__.py index a4d9f3b..fce1f5d 100644 --- a/graphblas_algorithms/__init__.py +++ b/graphblas_algorithms/__init__.py @@ -1,5 +1,5 @@ from . import _version -from .cluster import clustering, transitivity, triangles # noqa +from .cluster import average_clustering, clustering, transitivity, triangles # noqa from .link_analysis import pagerank # noqa __version__ = _version.get_versions()["version"] diff --git a/graphblas_algorithms/_utils.py b/graphblas_algorithms/_utils.py new file mode 100644 index 0000000..a633e7c --- /dev/null +++ b/graphblas_algorithms/_utils.py @@ -0,0 +1,46 @@ +import graphblas as gb +from graphblas import Vector, binary + + +def graph_to_adjacency(G, weight=None, dtype=None, *, name=None): + key_to_id = {k: i for i, k in enumerate(G)} + A = gb.io.from_networkx(G, nodelist=key_to_id, weight=weight, dtype=dtype, name=name) + return A, key_to_id + + +def dict_to_vector(d, key_to_id, *, size=None, dtype=None, name=None): + if d is None: + return None + if size is None: + size = len(key_to_id) + indices, values = zip(*((key_to_id[key], val) for key, val in d.items())) + return Vector.from_values(indices, values, size=size, dtype=dtype, name=name) + + +def list_to_vector(nodes, key_to_id, *, size=None, name=None): + if nodes is None: + return None, None + if size is None: + size = len(key_to_id) + id_to_key = {key_to_id[key]: key for key in nodes} + v = Vector.from_values(list(id_to_key), True, size=size, dtype=bool, name=name) + return v, id_to_key + + +def list_to_mask(nodes, key_to_id, *, size=None, name="mask"): + if nodes is None: + return None, None + v, id_to_key = list_to_vector(nodes, key_to_id, size=size, name=name) + return v.S, id_to_key + + +def vector_to_dict(v, key_to_id, id_to_key=None, *, mask=None, fillvalue=None): + # This mutates the vector to fill it! + if id_to_key is None: + id_to_key = {key_to_id[key]: key for key in key_to_id} + if mask is not None: + if fillvalue is not None and v.nvals < mask.parent.nvals: + v(mask, binary.first) << fillvalue + elif fillvalue is not None and v.nvals < v.size: + v(mask=~v.S) << fillvalue + return {id_to_key[index]: value for index, value in zip(*v.to_values(sort=False))} diff --git a/graphblas_algorithms/cluster.py b/graphblas_algorithms/cluster.py index 9fbc02a..90745b3 100644 --- a/graphblas_algorithms/cluster.py +++ b/graphblas_algorithms/cluster.py @@ -1,12 +1,13 @@ -from collections import OrderedDict - import graphblas as gb import networkx as nx -from graphblas import Matrix, Vector, agg, binary, select +from graphblas import Matrix, agg, select from graphblas.semiring import any_pair, plus_pair +from networkx import average_clustering as _nx_average_clustering from networkx import clustering as _nx_clustering from networkx.utils import not_implemented_for +from ._utils import graph_to_adjacency, list_to_mask, vector_to_dict + def get_properties(G, names, *, L=None, U=None, degrees=None, has_self_edges=True): """Calculate properties of undirected graph""" @@ -59,7 +60,7 @@ def get_degrees(G, mask=None, *, L=None, U=None, has_self_edges=True): def single_triangle_core(G, index, *, L=None, has_self_edges=True): M = Matrix(bool, G.nrows, G.ncols) - M[index, index] = False + M[index, index] = True C = any_pair(G.T @ M.T).new(name="C") # select.coleq(G.T, index) has_self_edges = get_properties(G, "has_self_edges", L=L, has_self_edges=has_self_edges) if has_self_edges: @@ -86,32 +87,17 @@ def triangles_core(G, mask=None, *, L=None, U=None): @not_implemented_for("directed") def triangles(G, nodes=None): - N = len(G) - if N == 0: + if len(G) == 0: return {} - node_ids = OrderedDict((k, i) for i, k in enumerate(G)) - A = gb.io.from_networkx(G, nodelist=node_ids, weight=None, dtype=bool) + A, key_to_id = graph_to_adjacency(G, dtype=bool) if nodes in G: - return single_triangle_core(A, node_ids[nodes]) - if nodes is not None: - id_to_key = {node_ids[key]: key for key in nodes} - mask = Vector.from_values(list(id_to_key), True, size=N, dtype=bool, name="mask").S - else: - mask = None + return single_triangle_core(A, key_to_id[nodes]) + mask, id_to_key = list_to_mask(nodes, key_to_id) result = triangles_core(A, mask=mask) - if nodes is not None: - if result.nvals != len(id_to_key): - result(mask, binary.first) << 0 - indices, values = result.to_values() - return {id_to_key[index]: value for index, value in zip(indices, values)} - elif result.nvals != N: - # Fill with zero - result(mask=~result.S) << 0 - return dict(zip(node_ids, result.to_values()[1])) + return vector_to_dict(result, key_to_id, id_to_key, mask=mask, fillvalue=0) def total_triangles_core(G, *, L=None, U=None): - # Ignores self-edges # We use SandiaDot method, because it's usually the fastest on large graphs. # For smaller graphs, Sandia method is usually faster: plus_pair(L @ L).new(mask=L.S) L, U = get_properties(G, "L U", L=L, U=U) @@ -161,28 +147,34 @@ def single_clustering_core(G, index, *, L=None, degrees=None, has_self_edges=Tru def clustering(G, nodes=None, weight=None): - N = len(G) - if N == 0: + if len(G) == 0: return {} if isinstance(G, nx.DiGraph) or weight is not None: # TODO: Not yet implemented. Clustering implemented only for undirected and unweighted. return _nx_clustering(G, nodes=nodes, weight=weight) - node_ids = OrderedDict((k, i) for i, k in enumerate(G)) - A = gb.io.from_networkx(G, nodelist=node_ids, weight=weight) + A, key_to_id = graph_to_adjacency(G, weight=weight) if nodes in G: - return single_clustering_core(A, node_ids[nodes]) - if nodes is not None: - id_to_key = {node_ids[key]: key for key in nodes} - mask = Vector.from_values(list(id_to_key), True, size=N, dtype=bool, name="mask").S - else: - mask = None + return single_clustering_core(A, key_to_id[nodes]) + mask, id_to_key = list_to_mask(nodes, key_to_id) result = clustering_core(A, mask=mask) - if nodes is not None: - if result.nvals != len(id_to_key): - result(mask, binary.first) << 0.0 - indices, values = result.to_values() - return {id_to_key[index]: value for index, value in zip(indices, values)} - elif result.nvals != N: - # Fill with zero - result(mask=~result.S) << 0.0 - return dict(zip(node_ids, result.to_values()[1])) + return vector_to_dict(result, key_to_id, id_to_key, mask=mask, fillvalue=0.0) + + +def average_clustering_core(G, mask=None, count_zeros=True, *, L=None, U=None, degrees=None): + c = clustering_core(G, mask=mask, L=L, U=U, degrees=degrees) + val = c.reduce(allow_empty=False).value + if not count_zeros: + return val / c.nvals + elif mask is not None: + return val / mask.parent.nvals + else: + return val / c.size + + +def average_clustering(G, nodes=None, weight=None, count_zeros=True): + if len(G) == 0 or isinstance(G, nx.DiGraph) or weight is not None: + # TODO: Not yet implemented. Clustering implemented only for undirected and unweighted. + return _nx_average_clustering(G, nodes=nodes, weight=weight, count_zeros=count_zeros) + A, key_to_id = graph_to_adjacency(G, weight=weight) + mask, _ = list_to_mask(nodes, key_to_id) + return average_clustering_core(A, mask=mask, count_zeros=count_zeros) diff --git a/graphblas_algorithms/link_analysis.py b/graphblas_algorithms/link_analysis.py index 3ed0e62..bf8389d 100644 --- a/graphblas_algorithms/link_analysis.py +++ b/graphblas_algorithms/link_analysis.py @@ -1,11 +1,11 @@ -from collections import OrderedDict from warnings import warn -import graphblas as gb import networkx as nx from graphblas import Vector, binary, unary from graphblas.semiring import plus_first, plus_times +from ._utils import dict_to_vector, graph_to_adjacency, vector_to_dict + def pagerank_core( A, @@ -44,7 +44,7 @@ def pagerank_core( # Inverse of row_degrees # Fold alpha constant into S if row_degrees is None: - S = A.reduce_rowwise().new(float, name="S") + S = A.reduce_rowwise().new(float, name="S") # XXX: What about self-edges S << alpha / S else: S = (alpha / row_degrees).new(name="S") @@ -119,26 +119,15 @@ def pagerank( N = len(G) if N == 0: return {} - node_ids = OrderedDict((k, i) for i, k in enumerate(G)) - A = gb.io.from_networkx(G, nodelist=node_ids, weight=weight, dtype=float) - - x = p = dangling_weights = None - # Initial vector (we'll normalize later) - if nstart is not None: - indices, values = zip(*((node_ids[key], val) for key, val in nstart.items())) - x = Vector.from_values(indices, values, size=N, dtype=float, name="nstart") - # Personalization vector (we'll normalize later) - if personalization is not None: - indices, values = zip(*((node_ids[key], val) for key, val in personalization.items())) - p = Vector.from_values(indices, values, size=N, dtype=float, name="personalization") - # Dangling nodes (we'll normalize later) - row_degrees = A.reduce_rowwise().new(name="row_degrees") - if dangling is not None: - if row_degrees.nvals < N: # is_dangling - indices, values = zip(*((node_ids[key], val) for key, val in dangling.items())) - dangling_weights = Vector.from_values( - indices, values, size=N, dtype=float, name="dangling" - ) + A, key_to_id = graph_to_adjacency(G, weight=weight, dtype=float) + # We'll normalize initial, personalization, and dangling vectors later + x = dict_to_vector(nstart, key_to_id, dtype=float, name="nstart") + p = dict_to_vector(personalization, key_to_id, dtype=float, name="personalization") + row_degrees = A.reduce_rowwise().new(name="row_degrees") # XXX: What about self-edges? + if dangling is not None and row_degrees.nvals < N: + dangling_weights = dict_to_vector(dangling, key_to_id, dtype=float, name="dangling") + else: + dangling_weights = None result = pagerank_core( A, alpha=alpha, @@ -149,7 +138,4 @@ def pagerank( dangling=dangling_weights, row_degrees=row_degrees, ) - if result.nvals != N: - # Not likely, but fill with 0 just in case - result(mask=~result.S) << 0 - return dict(zip(node_ids, result.to_values()[1])) + return vector_to_dict(result, key_to_id, fillvalue=0.0) diff --git a/graphblas_algorithms/tests/test_cluster.py b/graphblas_algorithms/tests/test_cluster.py index e5186ad..af5ec59 100644 --- a/graphblas_algorithms/tests/test_cluster.py +++ b/graphblas_algorithms/tests/test_cluster.py @@ -4,7 +4,7 @@ import networkx as nx import graphblas_algorithms as ga -from graphblas_algorithms import clustering, transitivity, triangles +from graphblas_algorithms import average_clustering, clustering, transitivity, triangles nx_triangles = nx.triangles nx.triangles = triangles @@ -21,6 +21,11 @@ nx.algorithms.clustering = clustering nx.algorithms.cluster.clustering = clustering +nx_average_clustering = nx.average_clustering +nx.average_clustering = average_clustering +nx.algorithms.average_clustering = average_clustering +nx.algorithms.cluster.average_clustering = average_clustering + def test_signatures(): nx_sig = inspect.signature(nx_triangles) @@ -78,6 +83,10 @@ def test_triangles_full(): assert result.isequal(expected) result = ga.cluster.clustering_core(G2, mask=mask.S) assert result.isequal(expected) + assert ga.cluster.average_clustering_core(G) == 1 + assert ga.cluster.average_clustering_core(G2) == 1 + assert ga.cluster.average_clustering_core(G, mask=mask.S) == 1 + assert ga.cluster.average_clustering_core(G2, mask=mask.S) == 1 from networkx.algorithms.tests.test_cluster import * # noqa isort:skip diff --git a/requirements.txt b/requirements.txt index ee57e55..d6ac249 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -python-graphblas >=2022.4.1 +python-graphblas >=2022.4.2 +networkx diff --git a/setup.py b/setup.py index ddcfd3f..e83e92d 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,8 @@ } extras_require["complete"] = sorted({v for req in extras_require.values() for v in req}) +with open("requirements.txt") as f: + install_requires = f.read().strip().split("\n") with open("README.md") as f: long_description = f.read() @@ -22,7 +24,7 @@ url="https://github.com/python-graphblas/graphblas-algorithms", packages=find_packages(), python_requires=">=3.8", - install_requires=["python-graphblas >=2022.4.1", "networkx"], + install_requires=install_requires, extras_require=extras_require, include_package_data=True, license="Apache License 2.0",