diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d22022e..1e6aa40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 88e81b0..892b0c7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - id: black # - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.263 + rev: v0.0.264 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -81,7 +81,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.263 + rev: v0.0.264 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. diff --git a/MANIFEST.in b/MANIFEST.in index 92306c0..c69947d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,3 @@ include setup.py include README.md include LICENSE include MANIFEST.in -docs/_static/img/logo-name-medium.png -docs/_static/img/graphblas-vs-igraph.png -docs/_static/img/graphblas-vs-networkx.png diff --git a/README.md b/README.md index 32039fb..821cd95 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![GraphBLAS Algorithms](docs/_static/img/logo-name-medium.svg) +![GraphBLAS Algorithms](https://raw.githubusercontent.com/python-graphblas/graphblas-algorithms/main/docs/_static/img/logo-name-medium.svg)
[![conda-forge](https://img.shields.io/conda/vn/conda-forge/graphblas-algorithms.svg)](https://anaconda.org/conda-forge/graphblas-algorithms) [![pypi](https://img.shields.io/pypi/v/graphblas-algorithms.svg)](https://pypi.python.org/pypi/graphblas-algorithms/) @@ -21,9 +21,9 @@ Why use GraphBLAS Algorithms? Because it is *fast*, *flexible*, and *familiar* b Are we missing any [algorithms](#Plugin-Algorithms) that you want? [Please let us know!](https://github.com/python-graphblas/graphblas-algorithms/issues)
-GraphBLAS vs NetworkX +GraphBLAS vs NetworkX
-GraphBLAS vs igraph +GraphBLAS vs igraph ### Installation ``` @@ -151,6 +151,8 @@ dispatch pattern shown above. - descendants - Dominating - is_dominating_set +- Generators + - ego_graph - Isolate - is_isolate - isolates diff --git a/graphblas_algorithms/__init__.py b/graphblas_algorithms/__init__.py index f9bbcf4..09418f5 100644 --- a/graphblas_algorithms/__init__.py +++ b/graphblas_algorithms/__init__.py @@ -3,6 +3,7 @@ from .classes import * from .algorithms import * # isort:skip +from .generators import * # isort:skip try: __version__ = importlib.metadata.version("graphblas-algorithms") diff --git a/graphblas_algorithms/algorithms/_bfs.py b/graphblas_algorithms/algorithms/_bfs.py new file mode 100644 index 0000000..31674ce --- /dev/null +++ b/graphblas_algorithms/algorithms/_bfs.py @@ -0,0 +1,150 @@ +"""BFS routines used by other algorithms""" + +import numpy as np +from graphblas import Matrix, Vector, binary, replace, unary +from graphblas.semiring import any_pair + + +def _get_cutoff(n, cutoff): + if cutoff is None or cutoff >= n: + return n # Everything + return cutoff + 1 # Inclusive + + +def _plain_bfs(G, source, *, cutoff=None): + index = G._key_to_id[source] + A = G.get_property("offdiag") + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q = Vector(bool, n, name="q") + v[index] = True + q[index] = True + any_pair_bool = any_pair[bool] + cutoff = _get_cutoff(n, cutoff) + for _i in range(1, cutoff): + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + break + v(q.S) << True + return v + + +def _bfs_level(G, source, cutoff=None, *, transpose=False, dtype=int): + if dtype == bool: + dtype = int + index = G._key_to_id[source] + A = G.get_property("offdiag") + if transpose and G.is_directed(): + A = A.T # TODO: should we use "AT" instead? + n = A.nrows + v = Vector(dtype, n, name="bfs_level") + q = Vector(bool, n, name="q") + v[index] = 0 + q[index] = True + any_pair_bool = any_pair[bool] + cutoff = _get_cutoff(n, cutoff) + for i in range(1, cutoff): + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + break + v(q.S) << i + return v + + +def _bfs_levels(G, nodes, cutoff=None, *, dtype=int): + if dtype == bool: + dtype = int + A = G.get_property("offdiag") + n = A.nrows + if nodes is None: + # TODO: `D = Vector.from_scalar(0, n, dtype).diag()` + D = Vector(dtype, n, name="bfs_levels_vector") + D << 0 + D = D.diag(name="bfs_levels") + else: + ids = G.list_to_ids(nodes) + D = Matrix.from_coo( + np.arange(len(ids), dtype=np.uint64), + ids, + 0, + dtype, + nrows=len(ids), + ncols=n, + name="bfs_levels", + ) + Q = unary.one[bool](D).new(name="Q") + any_pair_bool = any_pair[bool] + cutoff = _get_cutoff(n, cutoff) + for i in range(1, cutoff): + Q(~D.S, replace) << any_pair_bool(Q @ A) + if Q.nvals == 0: + break + D(Q.S) << i + return D + + +# TODO: benchmark this and the version commented out below +def _plain_bfs_bidirectional(G, source): + # Bi-directional BFS w/o symmetrizing the adjacency matrix + index = G._key_to_id[source] + A = G.get_property("offdiag") + # XXX: should we use `AT` if available? + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q_out = Vector(bool, n, name="q_out") + q_in = Vector(bool, n, name="q_in") + v[index] = True + q_in[index] = True + any_pair_bool = any_pair[bool] + is_out_empty = True + is_in_empty = False + for _i in range(1, n): + # Traverse out-edges from the most recent `q_in` and `q_out` + if is_out_empty: + q_out(~v.S) << any_pair_bool(q_in @ A) + else: + q_out << binary.any(q_out | q_in) + q_out(~v.S, replace) << any_pair_bool(q_out @ A) + is_out_empty = q_out.nvals == 0 + if not is_out_empty: + v(q_out.S) << True + elif is_in_empty: + break + # Traverse in-edges from the most recent `q_in` and `q_out` + if is_in_empty: + q_in(~v.S) << any_pair_bool(A @ q_out) + else: + q_in << binary.any(q_out | q_in) + q_in(~v.S, replace) << any_pair_bool(A @ q_in) + is_in_empty = q_in.nvals == 0 + if not is_in_empty: + v(q_in.S) << True + elif is_out_empty: + break + return v + + +""" +def _plain_bfs_bidirectional(G, source): + # Bi-directional BFS w/o symmetrizing the adjacency matrix + index = G._key_to_id[source] + A = G.get_property("offdiag") + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q = Vector(bool, n, name="q") + q2 = Vector(bool, n, name="q_2") + v[index] = True + q[index] = True + any_pair_bool = any_pair[bool] + for _i in range(1, n): + q2(~v.S, replace) << any_pair_bool(q @ A) + v(q2.S) << True + q(~v.S, replace) << any_pair_bool(A @ q) + if q.nvals == 0: + if q2.nvals == 0: + break + q, q2 = q2, q + elif q2.nvals != 0: + q << binary.any(q | q2) + return v +""" diff --git a/graphblas_algorithms/algorithms/centrality/eigenvector.py b/graphblas_algorithms/algorithms/centrality/eigenvector.py index 5172f61..e9385f3 100644 --- a/graphblas_algorithms/algorithms/centrality/eigenvector.py +++ b/graphblas_algorithms/algorithms/centrality/eigenvector.py @@ -1,11 +1,7 @@ from graphblas import Vector -from graphblas_algorithms.algorithms._helpers import is_converged, normalize -from graphblas_algorithms.algorithms.exceptions import ( - ConvergenceFailure, - GraphBlasAlgorithmException, - PointlessConcept, -) +from .._helpers import is_converged, normalize +from ..exceptions import ConvergenceFailure, GraphBlasAlgorithmException, PointlessConcept __all__ = ["eigenvector_centrality"] diff --git a/graphblas_algorithms/algorithms/centrality/katz.py b/graphblas_algorithms/algorithms/centrality/katz.py index 78de982..8087e85 100644 --- a/graphblas_algorithms/algorithms/centrality/katz.py +++ b/graphblas_algorithms/algorithms/centrality/katz.py @@ -2,11 +2,8 @@ from graphblas.core.utils import output_type from graphblas.semiring import plus_first, plus_times -from graphblas_algorithms.algorithms._helpers import is_converged, normalize -from graphblas_algorithms.algorithms.exceptions import ( - ConvergenceFailure, - GraphBlasAlgorithmException, -) +from .._helpers import is_converged, normalize +from ..exceptions import ConvergenceFailure, GraphBlasAlgorithmException __all__ = ["katz_centrality"] diff --git a/graphblas_algorithms/algorithms/components/connected.py b/graphblas_algorithms/algorithms/components/connected.py index 37c0fc9..fb2f678 100644 --- a/graphblas_algorithms/algorithms/components/connected.py +++ b/graphblas_algorithms/algorithms/components/connected.py @@ -1,7 +1,5 @@ -from graphblas import Vector, replace -from graphblas.semiring import any_pair - -from graphblas_algorithms.algorithms.exceptions import PointlessConcept +from .._bfs import _plain_bfs +from ..exceptions import PointlessConcept def is_connected(G): @@ -12,20 +10,3 @@ def is_connected(G): def node_connected_component(G, n): return _plain_bfs(G, n) - - -def _plain_bfs(G, source): - index = G._key_to_id[source] - A = G.get_property("offdiag") - n = A.nrows - v = Vector(bool, n, name="bfs_plain") - q = Vector(bool, n, name="q") - v[index] = True - q[index] = True - any_pair_bool = any_pair[bool] - for _i in range(1, n): - q(~v.S, replace) << any_pair_bool(q @ A) - if q.nvals == 0: - break - v(q.S) << True - return v diff --git a/graphblas_algorithms/algorithms/components/weakly_connected.py b/graphblas_algorithms/algorithms/components/weakly_connected.py index eb3dc75..99eba78 100644 --- a/graphblas_algorithms/algorithms/components/weakly_connected.py +++ b/graphblas_algorithms/algorithms/components/weakly_connected.py @@ -1,77 +1,8 @@ -from graphblas import Vector, binary, replace -from graphblas.semiring import any_pair - -from graphblas_algorithms.algorithms.exceptions import PointlessConcept +from .._bfs import _plain_bfs_bidirectional +from ..exceptions import PointlessConcept def is_weakly_connected(G): if len(G) == 0: raise PointlessConcept("Connectivity is undefined for the null graph.") - return _plain_bfs(G, next(iter(G))).nvals == len(G) - - -# TODO: benchmark this and the version commented out below -def _plain_bfs(G, source): - # Bi-directional BFS w/o symmetrizing the adjacency matrix - index = G._key_to_id[source] - A = G.get_property("offdiag") - # XXX: should we use `AT` if available? - n = A.nrows - v = Vector(bool, n, name="bfs_plain") - q_out = Vector(bool, n, name="q_out") - q_in = Vector(bool, n, name="q_in") - v[index] = True - q_in[index] = True - any_pair_bool = any_pair[bool] - is_out_empty = True - is_in_empty = False - for _i in range(1, n): - # Traverse out-edges from the most recent `q_in` and `q_out` - if is_out_empty: - q_out(~v.S) << any_pair_bool(q_in @ A) - else: - q_out << binary.any(q_out | q_in) - q_out(~v.S, replace) << any_pair_bool(q_out @ A) - is_out_empty = q_out.nvals == 0 - if not is_out_empty: - v(q_out.S) << True - elif is_in_empty: - break - # Traverse in-edges from the most recent `q_in` and `q_out` - if is_in_empty: - q_in(~v.S) << any_pair_bool(A @ q_out) - else: - q_in << binary.any(q_out | q_in) - q_in(~v.S, replace) << any_pair_bool(A @ q_in) - is_in_empty = q_in.nvals == 0 - if not is_in_empty: - v(q_in.S) << True - elif is_out_empty: - break - return v - - -""" -def _plain_bfs(G, source): - # Bi-directional BFS w/o symmetrizing the adjacency matrix - index = G._key_to_id[source] - A = G.get_property("offdiag") - n = A.nrows - v = Vector(bool, n, name="bfs_plain") - q = Vector(bool, n, name="q") - q2 = Vector(bool, n, name="q_2") - v[index] = True - q[index] = True - any_pair_bool = any_pair[bool] - for _i in range(1, n): - q2(~v.S, replace) << any_pair_bool(q @ A) - v(q2.S) << True - q(~v.S, replace) << any_pair_bool(A @ q) - if q.nvals == 0: - if q2.nvals == 0: - break - q, q2 = q2, q - elif q2.nvals != 0: - q << binary.any(q | q2) - return v -""" + return _plain_bfs_bidirectional(G, next(iter(G))).nvals == len(G) diff --git a/graphblas_algorithms/algorithms/core.py b/graphblas_algorithms/algorithms/core.py index 8133c71..a6ff26d 100644 --- a/graphblas_algorithms/algorithms/core.py +++ b/graphblas_algorithms/algorithms/core.py @@ -1,11 +1,12 @@ from graphblas import Matrix, monoid, replace, select, semiring -from graphblas_algorithms.classes.graph import Graph +from graphblas_algorithms import Graph __all__ = ["k_truss"] def k_truss(G: Graph, k) -> Graph: + # TODO: should we have an option to keep the output matrix the same size? # Ignore self-edges S = G.get_property("offdiag") @@ -32,6 +33,5 @@ def k_truss(G: Graph, k) -> Graph: Ktruss = C[indices, indices].new() # Convert back to networkx graph with correct node ids - keys = G.list_to_keys(indices) - key_to_id = dict(zip(keys, range(len(indices)))) + key_to_id = G.renumber_key_to_id(indices.tolist()) return Graph(Ktruss, key_to_id=key_to_id) diff --git a/graphblas_algorithms/algorithms/link_analysis/hits_alg.py b/graphblas_algorithms/algorithms/link_analysis/hits_alg.py index 515806e..662ac14 100644 --- a/graphblas_algorithms/algorithms/link_analysis/hits_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/hits_alg.py @@ -1,7 +1,7 @@ from graphblas import Vector -from graphblas_algorithms.algorithms._helpers import is_converged, normalize -from graphblas_algorithms.algorithms.exceptions import ConvergenceFailure +from .._helpers import is_converged, normalize +from ..exceptions import ConvergenceFailure __all__ = ["hits"] diff --git a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py index 518c09f..d665e98 100644 --- a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py @@ -3,8 +3,9 @@ from graphblas.semiring import plus_first, plus_times from graphblas_algorithms import Graph -from graphblas_algorithms.algorithms._helpers import is_converged -from graphblas_algorithms.algorithms.exceptions import ConvergenceFailure + +from .._helpers import is_converged +from ..exceptions import ConvergenceFailure __all__ = ["pagerank", "google_matrix"] diff --git a/graphblas_algorithms/algorithms/shortest_paths/unweighted.py b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py index 3c8243f..5062e87 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/unweighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py @@ -1,6 +1,6 @@ -import numpy as np -from graphblas import Matrix, Vector, replace, unary -from graphblas.semiring import any_pair +from graphblas import Matrix + +from .._bfs import _bfs_level, _bfs_levels __all__ = [ "single_source_shortest_path_length", @@ -25,59 +25,3 @@ def all_pairs_shortest_path_length(G, cutoff=None, *, nodes=None, expand_output= rv[ids, :] = D return rv return D - - -def _bfs_level(G, source, cutoff, *, transpose=False): - index = G._key_to_id[source] - A = G.get_property("offdiag") - if transpose and G.is_directed(): - A = A.T # TODO: should we use "AT" instead? - n = A.nrows - v = Vector(int, n, name="bfs_unweighted") - q = Vector(bool, n, name="q") - v[index] = 0 - q[index] = True - any_pair_bool = any_pair[bool] - if cutoff is None or cutoff >= n: - cutoff = n # Everything - else: - cutoff += 1 # Inclusive - for i in range(1, cutoff): - q(~v.S, replace) << any_pair_bool(q @ A) - if q.nvals == 0: - break - v(q.S) << i - return v - - -def _bfs_levels(G, nodes, cutoff): - A = G.get_property("offdiag") - n = A.nrows - if nodes is None: - # TODO: `D = Vector.from_scalar(0, n, dtype).diag()` - D = Vector(int, n, name="bfs_unweighted_vector") - D << 0 - D = D.diag(name="bfs_unweighted") - else: - ids = G.list_to_ids(nodes) - D = Matrix.from_coo( - np.arange(len(ids), dtype=np.uint64), - ids, - 0, - int, - nrows=len(ids), - ncols=n, - name="bfs_unweighted", - ) - Q = unary.one[bool](D).new(name="Q") - any_pair_bool = any_pair[bool] - if cutoff is None or cutoff >= n: - cutoff = n # Everything - else: - cutoff += 1 # Inclusive - for i in range(1, cutoff): - Q(~D.S, replace) << any_pair_bool(Q @ A) - if Q.nvals == 0: - break - D(Q.S) << i - return D diff --git a/graphblas_algorithms/algorithms/shortest_paths/weighted.py b/graphblas_algorithms/algorithms/shortest_paths/weighted.py index 8e6efef..fddf672 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/weighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/weighted.py @@ -2,6 +2,7 @@ from graphblas import Matrix, Vector, binary, monoid, replace, select, unary from graphblas.semiring import any_pair, min_plus +from .._bfs import _bfs_level, _bfs_levels from ..exceptions import Unbounded __all__ = [ @@ -11,14 +12,16 @@ ] -def single_source_bellman_ford_path_length(G, source): +def single_source_bellman_ford_path_length(G, source, *, cutoff=None): # No need for `is_weighted=` keyword, b/c this is assumed to be weighted (I think) index = G._key_to_id[source] if G.get_property("is_iso"): # If the edges are iso-valued (and positive), then we can simply do level BFS is_negative, iso_value = G.get_properties("has_negative_edges+ iso_value") if not is_negative: - d = _bfs_level(G, source, dtype=iso_value.dtype) + if cutoff is not None: + cutoff = int(cutoff // iso_value) + d = _bfs_level(G, source, cutoff, dtype=iso_value.dtype) if iso_value != 1: d *= iso_value return d @@ -49,6 +52,8 @@ def single_source_bellman_ford_path_length(G, source): # `cur` is the current frontier of values that improved in the previous iteration. # This means that in this iteration we drop values from `cur` that are not better. cur << min_plus(cur @ A) + if cutoff is not None: + cur << select.valuele(cur, cutoff) # Mask is True where cur not in d or cur < d mask << one(cur) @@ -63,6 +68,8 @@ def single_source_bellman_ford_path_length(G, source): else: # Check for negative cycle when for loop completes without breaking cur << min_plus(cur @ A) + if cutoff is not None: + cur << select.valuele(cur, cutoff) mask << binary.lt(cur & d) if mask.reduce(monoid.lor): raise Unbounded("Negative cycle detected.") @@ -157,56 +164,6 @@ def bellman_ford_path_lengths(G, nodes=None, *, expand_output=False): return D -def _bfs_level(G, source, *, dtype=int): - if dtype == bool: - dtype = int - index = G._key_to_id[source] - A = G.get_property("offdiag") - n = A.nrows - v = Vector(dtype, n, name="bfs_level") - q = Vector(bool, n, name="q") - v[index] = 0 - q[index] = True - any_pair_bool = any_pair[bool] - for i in range(1, n): - q(~v.S, replace) << any_pair_bool(q @ A) - if q.nvals == 0: - break - v(q.S) << i - return v - - -def _bfs_levels(G, nodes=None, *, dtype=int): - if dtype == bool: - dtype = int - A = G.get_property("offdiag") - n = A.nrows - if nodes is None: - # TODO: `D = Vector.from_scalar(0, n, dtype).diag()` - D = Vector(dtype, n, name="bfs_levels_vector") - D << 0 - D = D.diag(name="bfs_levels") - else: - ids = G.list_to_ids(nodes) - D = Matrix.from_coo( - np.arange(len(ids), dtype=np.uint64), - ids, - 0, - dtype, - nrows=len(ids), - ncols=n, - name="bfs_levels", - ) - Q = unary.one[bool](D).new(name="Q") - any_pair_bool = any_pair[bool] - for i in range(1, n): - Q(~D.S, replace) << any_pair_bool(Q @ A) - if Q.nvals == 0: - break - D(Q.S) << i - return D - - def negative_edge_cycle(G): # TODO: use a heuristic to try to stop early if G.is_directed(): diff --git a/graphblas_algorithms/algorithms/triads.py b/graphblas_algorithms/algorithms/triads.py index 54702c7..e6ec2be 100644 --- a/graphblas_algorithms/algorithms/triads.py +++ b/graphblas_algorithms/algorithms/triads.py @@ -1,5 +1,4 @@ -from graphblas_algorithms.classes.digraph import DiGraph -from graphblas_algorithms.classes.graph import Graph +from graphblas_algorithms import DiGraph, Graph __all__ = ["is_triad"] diff --git a/graphblas_algorithms/classes/_utils.py b/graphblas_algorithms/classes/_utils.py index 92febc5..65ae010 100644 --- a/graphblas_algorithms/classes/_utils.py +++ b/graphblas_algorithms/classes/_utils.py @@ -250,3 +250,12 @@ def _cacheit(self, key, func, *args, **kwargs): if key not in self._cache: self._cache[key] = func(*args, **kwargs) return self._cache[key] + + +def renumber_key_to_id(self, indices): + """Create `key_to_id` for e.g. a subgraph with node ids from `indices`""" + id_to_key = self.id_to_key + return {id_to_key[index]: i for i, index in enumerate(indices)} + # Alternative (about the same performance) + # keys = self.list_to_keys(indices) + # return dict(zip(keys, range(len(indices)))) diff --git a/graphblas_algorithms/classes/digraph.py b/graphblas_algorithms/classes/digraph.py index 83e7356..bae66ae 100644 --- a/graphblas_algorithms/classes/digraph.py +++ b/graphblas_algorithms/classes/digraph.py @@ -553,6 +553,7 @@ def __init__(self, incoming_graph_data=None, *, key_to_id=None, **attr): vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set _cacheit = _utils._cacheit + renumber_key_to_id = _utils.renumber_key_to_id # NetworkX methods def to_directed_class(self): @@ -598,6 +599,16 @@ def is_multigraph(self): def is_directed(self): return True + def to_undirected(self, reciprocal=False, as_view=False, *, name=None): + if as_view: + raise NotImplementedError("`as_vew=True` is not implemented in `G.to_undirected`") + A = self._A + if reciprocal: + B = binary.any(A & A.T).new(name=name) + else: + B = binary.any(A | A.T).new(name=name) + return Graph(B, key_to_id=self._key_to_id) + class MultiDiGraph(DiGraph): def is_multigraph(self): diff --git a/graphblas_algorithms/classes/graph.py b/graphblas_algorithms/classes/graph.py index 03a2893..06f82be 100644 --- a/graphblas_algorithms/classes/graph.py +++ b/graphblas_algorithms/classes/graph.py @@ -401,6 +401,7 @@ def __init__(self, incoming_graph_data=None, *, key_to_id=None, **attr): vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set _cacheit = _utils._cacheit + renumber_key_to_id = _utils.renumber_key_to_id # NetworkX methods def to_directed_class(self): diff --git a/graphblas_algorithms/generators/__init__.py b/graphblas_algorithms/generators/__init__.py new file mode 100644 index 0000000..65a6526 --- /dev/null +++ b/graphblas_algorithms/generators/__init__.py @@ -0,0 +1 @@ +from .ego import * diff --git a/graphblas_algorithms/generators/ego.py b/graphblas_algorithms/generators/ego.py new file mode 100644 index 0000000..26e9cf9 --- /dev/null +++ b/graphblas_algorithms/generators/ego.py @@ -0,0 +1,24 @@ +from ..algorithms.components.connected import _plain_bfs +from ..algorithms.shortest_paths.weighted import single_source_bellman_ford_path_length + +__all__ = ["ego_graph"] + + +def ego_graph(G, n, radius=1, center=True, undirected=False, is_weighted=False): + # TODO: should we have an option to keep the output matrix the same size? + if undirected and G.is_directed(): + # NOT COVERED + G2 = G.to_undirected() + else: + G2 = G + if is_weighted: + v = single_source_bellman_ford_path_length(G2, n, cutoff=radius) + else: + v = _plain_bfs(G2, n, cutoff=radius) + if not center: + del v[G._key_to_id[n]] + + indices, _ = v.to_coo(values=False) + A = G._A[indices, indices].new(name="ego") + key_to_id = G.renumber_key_to_id(indices.tolist()) + return type(G)(A, key_to_id=key_to_id) diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index 206d19c..94f02a6 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -45,6 +45,8 @@ class Dispatcher: descendants = nxapi.dag.descendants # Dominating is_dominating_set = nxapi.dominating.is_dominating_set + # Generators + ego_graph = nxapi.generators.ego.ego_graph # Isolate is_isolate = nxapi.isolate.is_isolate isolates = nxapi.isolate.isolates diff --git a/graphblas_algorithms/nxapi/__init__.py b/graphblas_algorithms/nxapi/__init__.py index 5ddc1fa..2d36017 100644 --- a/graphblas_algorithms/nxapi/__init__.py +++ b/graphblas_algorithms/nxapi/__init__.py @@ -7,6 +7,7 @@ from .cuts import * from .dag import * from .dominating import * +from .generators import * from .isolate import * from .link_analysis import * from .operators import * @@ -23,6 +24,7 @@ from . import cluster from . import community from . import components +from . import generators from . import link_analysis from . import operators from . import shortest_paths diff --git a/graphblas_algorithms/nxapi/generators/__init__.py b/graphblas_algorithms/nxapi/generators/__init__.py new file mode 100644 index 0000000..65a6526 --- /dev/null +++ b/graphblas_algorithms/nxapi/generators/__init__.py @@ -0,0 +1 @@ +from .ego import * diff --git a/graphblas_algorithms/nxapi/generators/ego.py b/graphblas_algorithms/nxapi/generators/ego.py new file mode 100644 index 0000000..e591cb3 --- /dev/null +++ b/graphblas_algorithms/nxapi/generators/ego.py @@ -0,0 +1,11 @@ +from graphblas_algorithms import generators +from graphblas_algorithms.classes.digraph import to_graph + +__all__ = ["ego_graph"] + + +def ego_graph(G, n, radius=1, center=True, undirected=False, distance=None): + G = to_graph(G, weight=distance) + return generators.ego_graph( + G, n, radius=radius, center=center, undirected=undirected, is_weighted=distance is not None + ) diff --git a/graphblas_algorithms/tests/test_match_nx.py b/graphblas_algorithms/tests/test_match_nx.py index c50896f..490d1d7 100644 --- a/graphblas_algorithms/tests/test_match_nx.py +++ b/graphblas_algorithms/tests/test_match_nx.py @@ -159,3 +159,35 @@ def test_dispatched_funcs_in_nxapi(nx_names_to_info, gb_names_to_info): print(" ", ":".join(path.rsplit(".", 1))) if failing: # pragma: no cover raise AssertionError + + +def test_print_dispatched_not_implemented(nx_names_to_info, gb_names_to_info): + """It may be informative to see the results from this to identify functions to implement. + + $ pytest -s -k test_print_dispatched_not_implemented + """ + not_implemented = nx_names_to_info.keys() - gb_names_to_info.keys() + fullnames = {next(iter(nx_names_to_info[name])).fullname for name in not_implemented} + print() + print("=================================================================================") + print("Functions dispatched in NetworkX that ARE NOT implemented in graphblas-algorithms") + print("---------------------------------------------------------------------------------") + for i, name in enumerate(sorted(fullnames)): + print(i, name) + print("=================================================================================") + + +def test_print_dispatched_implemented(nx_names_to_info, gb_names_to_info): + """It may be informative to see the results from this to identify implemented functions. + + $ pytest -s -k test_print_dispatched_implemented + """ + implemented = nx_names_to_info.keys() & gb_names_to_info.keys() + fullnames = {next(iter(nx_names_to_info[name])).fullname for name in implemented} + print() + print("=============================================================================") + print("Functions dispatched in NetworkX that ARE implemented in graphblas-algorithms") + print("-----------------------------------------------------------------------------") + for i, name in enumerate(sorted(fullnames)): + print(i, name) + print("=============================================================================") diff --git a/pyproject.toml b/pyproject.toml index df17600..1772aa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ keywords = [ "math", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: POSIX :: Linux", @@ -46,6 +46,7 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3 :: Only", "Intended Audience :: Developers", "Intended Audience :: Other Audience", @@ -78,12 +79,8 @@ test = [ "setuptools", "tomli", ] -complete = [ - "pytest", - "networkx >=3.0", - "scipy >=1.8", - "setuptools", - "tomli", +all = [ + "graphblas-algorithms[test]", ] [tool.setuptools] @@ -104,10 +101,12 @@ packages = [ "graphblas_algorithms.algorithms.tests", "graphblas_algorithms.algorithms.traversal", "graphblas_algorithms.classes", + "graphblas_algorithms.generators", "graphblas_algorithms.nxapi", "graphblas_algorithms.nxapi.centrality", "graphblas_algorithms.nxapi.community", "graphblas_algorithms.nxapi.components", + "graphblas_algorithms.nxapi.generators", "graphblas_algorithms.nxapi.link_analysis", "graphblas_algorithms.nxapi.operators", "graphblas_algorithms.nxapi.shortest_paths",