Skip to content

Commit 1180e1c

Browse files
authored
Add {all_pairs,single_source}_bellman_ford_path_length (#44)
* Add `{all_pairs,single_source}_bellman_ford_path_length` That is, add these to `nxapi`: - `all_pairs_bellman_ford_path_length` - `single_source_bellman_ford_path_length` * Implement `algorithms.bellman_ford_path_lengths` to compute in chunks * Ignore diagonals during Bellman-Ford * Add comment, and use `offdiag` more places. * Do level BFS for Bellman-Ford when iso-valued (and non-negative) * Use `"iso_value"` property more places instead of `A.ss.iso_value` * Fail fast in these unlikely, but easily detected, cases * Allow garbage collector to be enabled during benchmarks * Automatically choose appropriate chunksize * Use `nsplits="auto"` in square_clustering (default to 256 MB chunks)
1 parent 0b649b2 commit 1180e1c

File tree

21 files changed

+533
-81
lines changed

21 files changed

+533
-81
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ repos:
5656
# These versions need updated manually
5757
- flake8==6.0.0
5858
- flake8-comprehensions==3.10.1
59-
- flake8-bugbear==23.1.20
59+
- flake8-bugbear==23.2.13
6060
- flake8-simplify==0.19.3
6161
- repo: https://github.com/asottile/yesqa
6262
rev: v1.4.0

graphblas_algorithms/algorithms/centrality/katz.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ def katz_centrality(
3535
raise GraphBlasAlgorithmException("beta must have a value for every node")
3636

3737
A = G._A
38-
if A.ss.is_iso:
38+
if (iso_value := G.get_property("iso_value")) is not None:
3939
# Fold iso-value into alpha
40-
alpha *= A.ss.iso_value.get(1.0)
40+
alpha *= iso_value.get(1.0)
4141
semiring = plus_first[float]
4242
else:
4343
semiring = plus_times[float]

graphblas_algorithms/algorithms/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def k_truss(G: Graph, k) -> Graph:
2828
S = C
2929

3030
# Remove isolate nodes
31-
indices, _ = C.reduce_rowwise(monoid.any).to_coo()
31+
indices, _ = C.reduce_rowwise(monoid.any).to_coo(values=False)
3232
Ktruss = C[indices, indices].new()
3333

3434
# Convert back to networkx graph with correct node ids

graphblas_algorithms/algorithms/dag.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def descendants(G, source):
99
if source not in G._key_to_id:
1010
raise KeyError(f"The node {source} is not in the graph")
1111
index = G._key_to_id[source]
12-
A = G._A
12+
A = G.get_property("offdiag")
1313
q = Vector.from_coo(index, True, size=A.nrows, name="q")
1414
rv = q.dup(name="descendants")
1515
for _ in range(A.nrows):
@@ -25,7 +25,7 @@ def ancestors(G, source):
2525
if source not in G._key_to_id:
2626
raise KeyError(f"The node {source} is not in the graph")
2727
index = G._key_to_id[source]
28-
A = G._A
28+
A = G.get_property("offdiag")
2929
q = Vector.from_coo(index, True, size=A.nrows, name="q")
3030
rv = q.dup(name="descendants")
3131
for _ in range(A.nrows):

graphblas_algorithms/algorithms/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ class EmptyGraphError(GraphBlasAlgorithmException):
1212

1313
class PointlessConcept(GraphBlasAlgorithmException):
1414
pass
15+
16+
17+
class Unbounded(GraphBlasAlgorithmException):
18+
pass

graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,10 @@ def pagerank(
4949
row_degrees = G.get_property("plus_rowwise+") # XXX: What about self-edges?
5050
S = (alpha / row_degrees).new(name="S")
5151

52-
if A.ss.is_iso:
52+
if (iso_value := G.get_property("iso_value")) is not None:
5353
# Fold iso-value of A into S
5454
# This lets us use the plus_first semiring, which is faster
55-
iso_value = A.ss.iso_value
56-
if iso_value != 1:
55+
if iso_value.get(1) != 1:
5756
S *= iso_value
5857
semiring = plus_first[float]
5958
else:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from .dense import *
22
from .generic import *
3+
from .weighted import *

graphblas_algorithms/algorithms/shortest_paths/generic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def has_path(G, source, target):
1010
dst = G._key_to_id[target]
1111
if src == dst:
1212
return True
13-
A = G._A
13+
A = G.get_property("offdiag")
1414
q_src = Vector.from_coo(src, True, size=A.nrows, name="q_src")
1515
seen_src = q_src.dup(name="seen_src")
1616
q_dst = Vector.from_coo(dst, True, size=A.nrows, name="q_dst")
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import numpy as np
2+
from graphblas import Matrix, Vector, binary, monoid, replace, select, unary
3+
from graphblas.semiring import any_pair, min_plus
4+
5+
from ..exceptions import Unbounded
6+
7+
__all__ = [
8+
"single_source_bellman_ford_path_length",
9+
"bellman_ford_path_lengths",
10+
]
11+
12+
13+
def single_source_bellman_ford_path_length(G, source):
14+
# No need for `is_weighted=` keyword, b/c this is assumed to be weighted (I think)
15+
index = G._key_to_id[source]
16+
if G.get_property("is_iso"):
17+
# If the edges are iso-valued (and positive), then we can simply do level BFS
18+
is_negative, iso_value = G.get_properties("has_negative_edges+ iso_value")
19+
if not is_negative:
20+
d = _bfs_level(G, source, dtype=iso_value.dtype)
21+
if iso_value != 1:
22+
d *= iso_value
23+
return d
24+
# It's difficult to detect negative cycles with BFS
25+
if G._A[index, index].get() is not None:
26+
raise Unbounded("Negative cycle detected.")
27+
if not G.is_directed() and G._A[index, :].nvals > 0:
28+
# For undirected graphs, any negative edge is a cycle
29+
raise Unbounded("Negative cycle detected.")
30+
31+
# Use `offdiag` instead of `A`, b/c self-loops don't contribute to the result,
32+
# and negative self-loops are easy negative cycles to avoid.
33+
# We check if we hit a self-loop negative cycle at the end.
34+
A, has_negative_diagonal = G.get_properties("offdiag has_negative_diagonal")
35+
if A.dtype == bool:
36+
# Should we upcast e.g. INT8 to INT64 as well?
37+
dtype = int
38+
else:
39+
dtype = A.dtype
40+
n = A.nrows
41+
d = Vector(dtype, n, name="single_source_bellman_ford_path_length")
42+
d[index] = 0
43+
cur = d.dup(name="cur")
44+
mask = Vector(bool, n, name="mask")
45+
one = unary.one[bool]
46+
for _i in range(n - 1):
47+
# This is a slightly modified Bellman-Ford algorithm.
48+
# `cur` is the current frontier of values that improved in the previous iteration.
49+
# This means that in this iteration we drop values from `cur` that are not better.
50+
cur << min_plus(cur @ A)
51+
52+
# Mask is True where cur not in d or cur < d
53+
mask << one(cur)
54+
mask(binary.second) << binary.lt(cur & d)
55+
56+
# Drop values from `cur` that didn't improve
57+
cur(mask.V, replace) << cur
58+
if cur.nvals == 0:
59+
break
60+
# Update `d` with values that improved
61+
d(cur.S) << cur
62+
else:
63+
# Check for negative cycle when for loop completes without breaking
64+
cur << min_plus(cur @ A)
65+
mask << binary.lt(cur & d)
66+
if mask.reduce(monoid.lor):
67+
raise Unbounded("Negative cycle detected.")
68+
if has_negative_diagonal:
69+
# We removed diagonal entries above, so check if we visited one with a negative weight
70+
diag = G.get_property("diag")
71+
cur << select.valuelt(diag, 0)
72+
if any_pair(d @ cur):
73+
raise Unbounded("Negative cycle detected.")
74+
return d
75+
76+
77+
def bellman_ford_path_lengths(G, nodes=None, *, expand_output=False):
78+
"""
79+
80+
Parameters
81+
----------
82+
expand_output : bool, default False
83+
When False, the returned Matrix has one row per node in nodes.
84+
When True, the returned Matrix has the same shape as the input Matrix.
85+
"""
86+
# Same algorithms as in `single_source_bellman_ford_path_length`, but with
87+
# `Cur` as a Matrix with each row corresponding to a source node.
88+
if G.get_property("is_iso"):
89+
is_negative, iso_value = G.get_properties("has_negative_edges+ iso_value")
90+
if not is_negative:
91+
D = _bfs_levels(G, nodes, dtype=iso_value.dtype)
92+
if iso_value != 1:
93+
D *= iso_value
94+
if nodes is not None and expand_output and D.ncols != D.nrows:
95+
ids = G.list_to_ids(nodes)
96+
rv = Matrix(D.dtype, D.ncols, D.ncols, name=D.name)
97+
rv[ids, :] = D
98+
return rv
99+
return D
100+
if not G.is_directed():
101+
# For undirected graphs, any negative edge is a cycle
102+
if nodes is not None:
103+
ids = G.list_to_ids(nodes)
104+
if G._A[ids, :].nvals > 0:
105+
raise Unbounded("Negative cycle detected.")
106+
elif G._A.nvals > 0:
107+
raise Unbounded("Negative cycle detected.")
108+
109+
A, has_negative_diagonal = G.get_properties("offdiag has_negative_diagonal")
110+
if A.dtype == bool:
111+
dtype = int
112+
else:
113+
dtype = A.dtype
114+
n = A.nrows
115+
if nodes is None:
116+
# TODO: `D = Vector.from_scalar(0, n, dtype).diag()`
117+
D = Vector(dtype, n, name="bellman_ford_path_lengths_vector")
118+
D << 0
119+
D = D.diag(name="bellman_ford_path_lengths")
120+
else:
121+
ids = G.list_to_ids(nodes)
122+
D = Matrix.from_coo(
123+
np.arange(len(ids), dtype=np.uint64),
124+
ids,
125+
0,
126+
dtype,
127+
nrows=len(ids),
128+
ncols=n,
129+
name="bellman_ford_path_lengths",
130+
)
131+
Cur = D.dup(name="Cur")
132+
Mask = Matrix(bool, D.nrows, D.ncols, name="Mask")
133+
one = unary.one[bool]
134+
for _i in range(n - 1):
135+
Cur << min_plus(Cur @ A)
136+
Mask << one(Cur)
137+
Mask(binary.second) << binary.lt(Cur & D)
138+
Cur(Mask.V, replace) << Cur
139+
if Cur.nvals == 0:
140+
break
141+
D(Cur.S) << Cur
142+
else:
143+
Cur << min_plus(Cur @ A)
144+
Mask << binary.lt(Cur & D)
145+
if Mask.reduce_scalar(monoid.lor):
146+
raise Unbounded("Negative cycle detected.")
147+
if has_negative_diagonal:
148+
diag = G.get_property("diag")
149+
cur = select.valuelt(diag, 0)
150+
if any_pair(D @ cur).nvals > 0:
151+
raise Unbounded("Negative cycle detected.")
152+
if nodes is not None and expand_output and D.ncols != D.nrows:
153+
rv = Matrix(D.dtype, n, n, name=D.name)
154+
rv[ids, :] = D
155+
return rv
156+
return D
157+
158+
159+
def _bfs_level(G, source, *, dtype=int):
160+
if dtype == bool:
161+
dtype = int
162+
index = G._key_to_id[source]
163+
A = G.get_property("offdiag")
164+
n = A.nrows
165+
v = Vector(dtype, n, name="bfs_level")
166+
q = Vector(bool, n, name="q")
167+
v[index] = 0
168+
q[index] = True
169+
any_pair_bool = any_pair[bool]
170+
for i in range(1, n):
171+
q(~v.S, replace) << any_pair_bool(q @ A)
172+
if q.nvals == 0:
173+
break
174+
v(q.S) << i
175+
return v
176+
177+
178+
def _bfs_levels(G, nodes=None, *, dtype=int):
179+
if dtype == bool:
180+
dtype = int
181+
A = G.get_property("offdiag")
182+
n = A.nrows
183+
if nodes is None:
184+
# TODO: `D = Vector.from_scalar(0, n, dtype).diag()`
185+
D = Vector(dtype, n, name="bfs_levels_vector")
186+
D << 0
187+
D = D.diag(name="bfs_levels")
188+
else:
189+
ids = G.list_to_ids(nodes)
190+
D = Matrix.from_coo(
191+
np.arange(len(ids), dtype=np.uint64),
192+
ids,
193+
0,
194+
dtype,
195+
nrows=len(ids),
196+
ncols=n,
197+
name="bfs_levels",
198+
)
199+
Q = Matrix(bool, D.nrows, D.ncols, name="Q")
200+
Q << unary.one[bool](D)
201+
any_pair_bool = any_pair[bool]
202+
for i in range(1, n):
203+
Q(~D.S, replace) << any_pair_bool(Q @ A)
204+
if Q.nvals == 0:
205+
break
206+
D(Q.S) << i
207+
return D

graphblas_algorithms/algorithms/simple_paths.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def is_simple_path(G, nodes):
88
return False
99
if len(nodes) == 1:
1010
return nodes[0] in G
11-
A = G._A
11+
A = G._A # offdiag instead?
1212
if A.nvals < len(nodes) - 1:
1313
return False
1414
key_to_id = G._key_to_id

graphblas_algorithms/classes/_caching.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
from graphblas import op
1+
from graphblas import Scalar, dtypes, op
22
from graphblas.core import operator
33

4+
NONNEGATIVE_DTYPES = {dtypes.BOOL, dtypes.UINT8, dtypes.UINT16, dtypes.UINT32, dtypes.UINT64}
5+
46

57
def get_reduce_to_vector(key, opname, methodname):
68
op_ = op.from_string(opname)
@@ -142,7 +144,7 @@ def get_reduction(G, mask=None):
142144
cache[f"{keybase}+"] = cache[key]
143145
return cache[key]
144146

145-
else:
147+
elif key[-1] == "+":
146148

147149
def get_reduction(G, mask=None):
148150
A = G._A
@@ -170,4 +172,18 @@ def get_reduction(G, mask=None):
170172
cache[f"{keybase}-"] = cache[key]
171173
return cache[key]
172174

175+
elif key.endswith("_diagonal"):
176+
177+
def get_reduction(G, mask=None):
178+
A = G._A
179+
cache = G._cache
180+
if key not in cache:
181+
if not G.get_property("has_self_edges"):
182+
cache[key] = Scalar(op_[A.dtype].return_type, name=key)
183+
else:
184+
cache[key] = G.get_property("diag").reduce(op_).new(name=key)
185+
return cache[key]
186+
187+
else: # pragma: no cover (sanity)
188+
raise RuntimeError()
173189
return get_reduction

graphblas_algorithms/classes/_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@ def dict_to_vector(self, d, *, size=None, dtype=None, name=None):
6565
return Vector.from_coo(indices, values, size=size, dtype=dtype, name=name)
6666

6767

68-
def list_to_vector(self, nodes, dtype=bool, *, size=None, name=None):
68+
def list_to_vector(self, nodes, dtype=None, *, values=True, size=None, name=None):
6969
if nodes is None:
7070
return None
7171
if size is None:
7272
size = len(self)
7373
key_to_id = self._key_to_id
7474
index = [key_to_id[key] for key in nodes]
75-
return Vector.from_coo(index, True, size=size, dtype=dtype, name=name)
75+
return Vector.from_coo(index, values, size=size, dtype=dtype, name=name)
7676

7777

7878
def list_to_mask(self, nodes, *, size=None, name="mask"):

0 commit comments

Comments
 (0)